From fb2b8ce372b28a2fdaac7bfed58c58387f26cf3e Mon Sep 17 00:00:00 2001 From: jonatanritter Date: Wed, 22 Jul 2026 18:25:08 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20portability=20safeguards=20=E2=80=94=20?= =?UTF-8?q?signed=20goldens,=20HTTP=20contract=20suite,=20committed=20Open?= =?UTF-8?q?API=20(Task=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/openapi-v1.json | 1867 +++++++++++++++++ scripts/generate_signed_goldens.py | 55 + scripts/generate_test_cert.py | 67 + tests/fixtures/test_cert.pfx | Bin 0 -> 2686 bytes tests/goldens_signed/caso_com_ub.expected.xml | 18 + .../caso_devolucao_intra.expected.xml | 18 + .../caso_fracao_centavo.expected.xml | 18 + .../caso_padrao_inter.expected.xml | 18 + .../caso_padrao_intra.expected.xml | 18 + .../goldens_signed/caso_st_intra.expected.xml | 18 + tests/test_contract.py | 400 ++++ tests/test_openapi_committed.py | 31 + tests/test_signed_goldens.py | 160 ++ 13 files changed, 2688 insertions(+) create mode 100644 docs/openapi-v1.json create mode 100644 scripts/generate_signed_goldens.py create mode 100644 scripts/generate_test_cert.py create mode 100644 tests/fixtures/test_cert.pfx create mode 100644 tests/goldens_signed/caso_com_ub.expected.xml create mode 100644 tests/goldens_signed/caso_devolucao_intra.expected.xml create mode 100644 tests/goldens_signed/caso_fracao_centavo.expected.xml create mode 100644 tests/goldens_signed/caso_padrao_inter.expected.xml create mode 100644 tests/goldens_signed/caso_padrao_intra.expected.xml create mode 100644 tests/goldens_signed/caso_st_intra.expected.xml create mode 100644 tests/test_contract.py create mode 100644 tests/test_openapi_committed.py create mode 100644 tests/test_signed_goldens.py diff --git a/docs/openapi-v1.json b/docs/openapi-v1.json new file mode 100644 index 0000000..050e838 --- /dev/null +++ b/docs/openapi-v1.json @@ -0,0 +1,1867 @@ +{ + "components": { + "schemas": { + "Body_upload_certificate_endpoint_v1_certificados_post": { + "properties": { + "cnpj": { + "maxLength": 14, + "minLength": 14, + "title": "Cnpj", + "type": "string" + }, + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + } + }, + "required": [ + "cnpj", + "file", + "password" + ], + "title": "Body_upload_certificate_endpoint_v1_certificados_post", + "type": "object" + }, + "DestinatarioDataPayload": { + "description": "`None` no `EmissaoRequest.destinatario` == consumidor final n\u00e3o\nidentificado -- espelha `sowai_fiscal.xml_builder.DestinatarioData`.", + "properties": { + "address_city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address City" + }, + "address_city_ibge_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address City Ibge Code" + }, + "address_complement": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address Complement" + }, + "address_district": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address District" + }, + "address_number": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address Number" + }, + "address_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address State" + }, + "address_street": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address Street" + }, + "address_zip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address Zip" + }, + "cnpj": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cnpj" + }, + "cpf": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cpf" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "ie": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ie" + }, + "indicador_ie": { + "title": "Indicador Ie", + "type": "string" + }, + "nome": { + "title": "Nome", + "type": "string" + } + }, + "required": [ + "nome", + "indicador_ie" + ], + "title": "DestinatarioDataPayload", + "type": "object" + }, + "EmissaoRequest": { + "properties": { + "ambiente": { + "enum": [ + "homologacao", + "producao" + ], + "title": "Ambiente", + "type": "string" + }, + "branch_ref": { + "maxLength": 64, + "title": "Branch Ref", + "type": "string" + }, + "destinatario": { + "anyOf": [ + { + "$ref": "#/components/schemas/DestinatarioDataPayload" + }, + { + "type": "null" + } + ] + }, + "document_model": { + "const": "55", + "default": "55", + "title": "Document Model", + "type": "string" + }, + "emitente": { + "$ref": "#/components/schemas/EmitenteDataPayload" + }, + "fin_nfe": { + "default": "1", + "maxLength": 1, + "minLength": 1, + "title": "Fin Nfe", + "type": "string" + }, + "ind_final": { + "default": "1", + "maxLength": 1, + "minLength": 1, + "title": "Ind Final", + "type": "string" + }, + "ind_pres": { + "default": "1", + "maxLength": 1, + "minLength": 1, + "title": "Ind Pres", + "type": "string" + }, + "itens": { + "items": { + "$ref": "#/components/schemas/ItemDataPayload" + }, + "minItems": 1, + "title": "Itens", + "type": "array" + }, + "nat_op": { + "default": "Venda", + "title": "Nat Op", + "type": "string" + }, + "pagamento": { + "$ref": "#/components/schemas/PagamentoDataPayload" + }, + "serie": { + "minimum": 0.0, + "title": "Serie", + "type": "integer" + }, + "tenant_ref": { + "maxLength": 64, + "title": "Tenant Ref", + "type": "string" + }, + "tp_emis": { + "default": "1", + "maxLength": 1, + "minLength": 1, + "title": "Tp Emis", + "type": "string" + }, + "uf_destino_tipo": { + "enum": [ + "interna", + "interestadual" + ], + "title": "Uf Destino Tipo", + "type": "string" + }, + "ver_proc": { + "maxLength": 20, + "minLength": 1, + "title": "Ver Proc", + "type": "string" + } + }, + "required": [ + "tenant_ref", + "branch_ref", + "serie", + "emitente", + "itens", + "pagamento", + "ambiente", + "uf_destino_tipo", + "ver_proc" + ], + "title": "EmissaoRequest", + "type": "object" + }, + "EmitenteDataPayload": { + "properties": { + "address_city": { + "title": "Address City", + "type": "string" + }, + "address_city_ibge_code": { + "title": "Address City Ibge Code", + "type": "string" + }, + "address_complement": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address Complement" + }, + "address_district": { + "title": "Address District", + "type": "string" + }, + "address_number": { + "title": "Address Number", + "type": "string" + }, + "address_state": { + "maxLength": 2, + "minLength": 2, + "title": "Address State", + "type": "string" + }, + "address_street": { + "title": "Address Street", + "type": "string" + }, + "address_zip": { + "title": "Address Zip", + "type": "string" + }, + "cnpj": { + "maxLength": 14, + "minLength": 14, + "title": "Cnpj", + "type": "string" + }, + "crt": { + "maxLength": 1, + "minLength": 1, + "title": "Crt", + "type": "string" + }, + "fone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fone" + }, + "ie": { + "title": "Ie", + "type": "string" + }, + "nome_fantasia": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nome Fantasia" + }, + "razao_social": { + "title": "Razao Social", + "type": "string" + } + }, + "required": [ + "cnpj", + "razao_social", + "ie", + "crt", + "address_street", + "address_number", + "address_district", + "address_city", + "address_state", + "address_zip", + "address_city_ibge_code" + ], + "title": "EmitenteDataPayload", + "type": "object" + }, + "FiscalCertificateRead": { + "description": "Metadados do A1 -- NUNCA o bin\u00e1rio (`pfx_encrypted`/\n`password_encrypted` ficam de fora de prop\u00f3sito; ver\n`certificates.router.read_certificate_endpoint`'s docstring).", + "properties": { + "branch_ref": { + "title": "Branch Ref", + "type": "string" + }, + "cnpj": { + "title": "Cnpj", + "type": "string" + }, + "cnpj_certificado": { + "title": "Cnpj Certificado", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "not_valid_after": { + "format": "date-time", + "title": "Not Valid After", + "type": "string" + }, + "not_valid_before": { + "format": "date-time", + "title": "Not Valid Before", + "type": "string" + }, + "product_id": { + "format": "uuid", + "title": "Product Id", + "type": "string" + }, + "subject_cn": { + "title": "Subject Cn", + "type": "string" + }, + "tenant_ref": { + "title": "Tenant Ref", + "type": "string" + } + }, + "required": [ + "id", + "product_id", + "tenant_ref", + "branch_ref", + "cnpj", + "subject_cn", + "cnpj_certificado", + "not_valid_before", + "not_valid_after", + "created_at" + ], + "title": "FiscalCertificateRead", + "type": "object" + }, + "FiscalDocumentRead": { + "description": "NUNCA inclui `xml_assinado` -- o XML sai s\u00f3 por `GET /v1/documentos/\n{id}/xml` (`Response(media_type=\"application/xml\")`), mesmo racional de\n`FiscalCertificateRead` nunca incluir o bin\u00e1rio do certificado. Sem\n`sale_id`/`service_order_id` (porte table: este servi\u00e7o n\u00e3o conhece o\ndom\u00ednio do produto chamador).", + "properties": { + "ambiente": { + "title": "Ambiente", + "type": "string" + }, + "autorizada_em": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Autorizada Em" + }, + "branch_ref": { + "title": "Branch Ref", + "type": "string" + }, + "chave_acesso": { + "title": "Chave Acesso", + "type": "string" + }, + "codigo_numerico": { + "title": "Codigo Numerico", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "document_model": { + "title": "Document Model", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "numero": { + "title": "Numero", + "type": "integer" + }, + "product_id": { + "format": "uuid", + "title": "Product Id", + "type": "string" + }, + "protocolo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Protocolo" + }, + "rejeicao_codigo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rejeicao Codigo" + }, + "rejeicao_motivo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rejeicao Motivo" + }, + "serie": { + "title": "Serie", + "type": "integer" + }, + "series_id": { + "format": "uuid", + "title": "Series Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "tenant_ref": { + "title": "Tenant Ref", + "type": "string" + } + }, + "required": [ + "id", + "product_id", + "tenant_ref", + "branch_ref", + "series_id", + "document_model", + "serie", + "numero", + "chave_acesso", + "codigo_numerico", + "status", + "ambiente", + "rejeicao_codigo", + "rejeicao_motivo", + "protocolo", + "autorizada_em", + "created_at" + ], + "title": "FiscalDocumentRead", + "type": "object" + }, + "FiscalResultPayload": { + "properties": { + "cfop": { + "title": "Cfop", + "type": "string" + }, + "consumidor_final": { + "title": "Consumidor Final", + "type": "boolean" + }, + "csosn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Csosn" + }, + "cst": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cst" + }, + "indicador_ie": { + "title": "Indicador Ie", + "type": "string" + }, + "origem": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origem" + }, + "tributos": { + "items": { + "$ref": "#/components/schemas/TributoLinhaPayload" + }, + "title": "Tributos", + "type": "array" + } + }, + "required": [ + "cfop", + "consumidor_final", + "indicador_ie" + ], + "title": "FiscalResultPayload", + "type": "object" + }, + "FiscalSeriesCreate": { + "description": "POST /v1/series body. `serie` allows `0` (SEFAZ convention for\n\"s\u00e9rie \u00fanica\"/sem s\u00e9rie formal) -- `ge=0`, unlike `next_number` which\nmust start at 1 (`ge=1`, a document numbering never starts at 0). Only\n`\"55\"` is accepted for now (Global Constraints/spec: F2 covers NF-e 55\nemission only -- `xml_builder.build_nfe` itself hardcodes `mod=\"55\"`;\naccepting `\"65\"` here would create a series nothing can ever allocate\nagainst without silently wrong output).", + "properties": { + "branch_ref": { + "maxLength": 64, + "title": "Branch Ref", + "type": "string" + }, + "document_model": { + "const": "55", + "default": "55", + "title": "Document Model", + "type": "string" + }, + "next_number": { + "minimum": 1.0, + "title": "Next Number", + "type": "integer" + }, + "serie": { + "minimum": 0.0, + "title": "Serie", + "type": "integer" + }, + "tenant_ref": { + "maxLength": 64, + "title": "Tenant Ref", + "type": "string" + } + }, + "required": [ + "tenant_ref", + "branch_ref", + "serie", + "next_number" + ], + "title": "FiscalSeriesCreate", + "type": "object" + }, + "FiscalSeriesPatch": { + "additionalProperties": false, + "description": "PATCH /v1/series/{series_id} body -- ONLY `next_number` is editable\n(a manual correction, e.g. realigning the counter after a migration\nfrom another emissor). `tenant_ref`/`branch_ref`/`document_model`/\n`serie` are immutable once created -- delete and recreate the series\ninstead if it was set up wrong (no DELETE endpoint exists yet for this\nresource per the F2 API contract; ported convention documented here for\nwhen it does). `next_number` maps to a NOT NULL column, so explicit\n`null` is rejected with 422, same convention as the auto's\n`FiscalDocumentSeriesPatch`.", + "properties": { + "next_number": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Number" + } + }, + "title": "FiscalSeriesPatch", + "type": "object" + }, + "FiscalSeriesRead": { + "properties": { + "branch_ref": { + "title": "Branch Ref", + "type": "string" + }, + "document_model": { + "title": "Document Model", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "next_number": { + "title": "Next Number", + "type": "integer" + }, + "product_id": { + "format": "uuid", + "title": "Product Id", + "type": "string" + }, + "serie": { + "title": "Serie", + "type": "integer" + }, + "tenant_ref": { + "title": "Tenant Ref", + "type": "string" + } + }, + "required": [ + "id", + "product_id", + "tenant_ref", + "branch_ref", + "document_model", + "serie", + "next_number" + ], + "title": "FiscalSeriesRead", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "ItemDataPayload": { + "properties": { + "cest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cest" + }, + "cfop": { + "title": "Cfop", + "type": "string" + }, + "codigo": { + "title": "Codigo", + "type": "string" + }, + "descricao": { + "title": "Descricao", + "type": "string" + }, + "fiscal_result": { + "$ref": "#/components/schemas/FiscalResultPayload" + }, + "gtin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gtin" + }, + "ncm": { + "title": "Ncm", + "type": "string" + }, + "peso_bruto_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Peso Bruto Kg" + }, + "peso_liquido_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Peso Liquido Kg" + }, + "quantidade": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Quantidade" + }, + "unidade_comercial": { + "title": "Unidade Comercial", + "type": "string" + }, + "unidade_tributavel": { + "title": "Unidade Tributavel", + "type": "string" + }, + "valor_unitario": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Valor Unitario" + } + }, + "required": [ + "codigo", + "descricao", + "ncm", + "cfop", + "unidade_comercial", + "unidade_tributavel", + "quantidade", + "valor_unitario", + "fiscal_result" + ], + "title": "ItemDataPayload", + "type": "object" + }, + "PagamentoDataPayload": { + "properties": { + "indpag": { + "default": "0", + "title": "Indpag", + "type": "string" + }, + "tpag": { + "title": "Tpag", + "type": "string" + }, + "valor": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Valor" + } + }, + "required": [ + "tpag", + "valor" + ], + "title": "PagamentoDataPayload", + "type": "object" + }, + "TributoLinhaPayload": { + "properties": { + "aliquota": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Aliquota" + }, + "aliquota_st": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Aliquota St" + }, + "base_calc": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Base Calc" + }, + "base_calc_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Base Calc Percent" + }, + "codigo_beneficio": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Codigo Beneficio" + }, + "csosn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Csosn" + }, + "cst": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cst" + }, + "fcp_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fcp Percent" + }, + "mva": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mva" + }, + "rule_id": { + "format": "uuid", + "title": "Rule Id", + "type": "string" + }, + "tax_domain": { + "title": "Tax Domain", + "type": "string" + }, + "valor": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Valor" + } + }, + "required": [ + "tax_domain", + "base_calc", + "base_calc_percent", + "valor", + "rule_id" + ], + "title": "TributoLinhaPayload", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "sowai-fiscal-svc", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/v1/certificados": { + "delete": { + "operationId": "deactivate_certificate_endpoint_v1_certificados_delete", + "parameters": [ + { + "in": "query", + "name": "tenant_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Tenant Ref", + "type": "string" + } + }, + { + "in": "query", + "name": "branch_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Branch Ref", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Deactivate Certificate Endpoint", + "tags": [ + "certificados" + ] + }, + "get": { + "description": "Metadados do certificado VIVO -- NUNCA o bin\u00e1rio (o .pfx/senha\ncifrados nunca saem do banco por esta rota; s\u00f3 `certificates.crypto.\nload_private_key_and_cert`, uso interno da emiss\u00e3o, Task 5, os\ndescriptografa, e s\u00f3 em mem\u00f3ria).", + "operationId": "read_certificate_endpoint_v1_certificados_get", + "parameters": [ + { + "in": "query", + "name": "tenant_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Tenant Ref", + "type": "string" + } + }, + { + "in": "query", + "name": "branch_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Branch Ref", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalCertificateRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Read Certificate Endpoint", + "tags": [ + "certificados" + ] + }, + "post": { + "operationId": "upload_certificate_endpoint_v1_certificados_post", + "parameters": [ + { + "in": "query", + "name": "tenant_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Tenant Ref", + "type": "string" + } + }, + { + "in": "query", + "name": "branch_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Branch Ref", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_certificate_endpoint_v1_certificados_post" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalCertificateRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Certificate Endpoint", + "tags": [ + "certificados" + ] + } + }, + "/v1/documentos": { + "get": { + "operationId": "list_fiscal_documents_endpoint_v1_documentos_get", + "parameters": [ + { + "in": "query", + "name": "tenant_ref", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tenant Ref" + } + }, + { + "in": "query", + "name": "branch_ref", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Branch Ref" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 20, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FiscalDocumentRead" + }, + "title": "Response List Fiscal Documents Endpoint V1 Documentos Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Fiscal Documents Endpoint", + "tags": [ + "emissao" + ] + } + }, + "/v1/documentos/{document_id}": { + "get": { + "operationId": "get_fiscal_document_endpoint_v1_documentos__document_id__get", + "parameters": [ + { + "in": "path", + "name": "document_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Document Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalDocumentRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Fiscal Document Endpoint", + "tags": [ + "emissao" + ] + } + }, + "/v1/documentos/{document_id}/xml": { + "get": { + "operationId": "get_fiscal_document_xml_endpoint_v1_documentos__document_id__xml_get", + "parameters": [ + { + "in": "path", + "name": "document_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Document Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Fiscal Document Xml Endpoint", + "tags": [ + "emissao" + ] + } + }, + "/v1/emissoes": { + "post": { + "description": "`Idempotency-Key` OBRIGAT\u00d3RIO (design spec decis\u00e3o #6) -- ausente\nvira 422 na borda do FastAPI (`Header(...)`, sem default), antes de\nqualquer l\u00f3gica rodar. Repetida com a MESMA `product_id` -> 200 com o\ndocumento j\u00e1 existente (`created=False`); nova -> 201 (`created=True`).\nVer `emission.service.emitir_documento`'s docstring para o padr\u00e3o de\nduas camadas que garante isto mesmo sob duas requisi\u00e7\u00f5es concorrentes\ncom a mesma chave.", + "operationId": "emitir_documento_endpoint_v1_emissoes_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 255, + "minLength": 1, + "title": "Idempotency-Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmissaoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalDocumentRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Emitir Documento Endpoint", + "tags": [ + "emissao" + ] + } + }, + "/v1/health": { + "get": { + "operationId": "health_check_v1_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Health Check V1 Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health Check" + } + }, + "/v1/series": { + "get": { + "operationId": "list_fiscal_series_endpoint_v1_series_get", + "parameters": [ + { + "in": "query", + "name": "tenant_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Tenant Ref", + "type": "string" + } + }, + { + "in": "query", + "name": "branch_ref", + "required": true, + "schema": { + "maxLength": 64, + "title": "Branch Ref", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FiscalSeriesRead" + }, + "title": "Response List Fiscal Series Endpoint V1 Series Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Fiscal Series Endpoint", + "tags": [ + "series" + ] + }, + "post": { + "operationId": "create_fiscal_series_endpoint_v1_series_post", + "parameters": [ + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalSeriesCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalSeriesRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Fiscal Series Endpoint", + "tags": [ + "series" + ] + } + }, + "/v1/series/{series_id}": { + "patch": { + "operationId": "update_fiscal_series_endpoint_v1_series__series_id__patch", + "parameters": [ + { + "in": "path", + "name": "series_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Series Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Api-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalSeriesPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FiscalSeriesRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Fiscal Series Endpoint", + "tags": [ + "series" + ] + } + } + } +} diff --git a/scripts/generate_signed_goldens.py b/scripts/generate_signed_goldens.py new file mode 100644 index 0000000..65963fb --- /dev/null +++ b/scripts/generate_signed_goldens.py @@ -0,0 +1,55 @@ +"""Task 6: one-shot generator for `tests/goldens_signed/*.expected.xml` -- +NOT run automatically by the test suite. Run manually whenever a golden case +in the `sowai-fiscal` lib is added/changed (or `tests/fixtures/test_cert.pfx` +is rotated), then commit the regenerated files. + +Signs each `sowai_fiscal.goldens/*.input.json` case's `DadosEmissao` (loaded +via `sowai_fiscal.golden_helpers.dados_from_json` -- SAME helper the lib's +own pre-signature goldens use, so `chave_acesso`/`numero`/`cnf`/`dh_emi` are +whatever that JSON already pins, unchanged) with the FIXED test certificate. +Deterministic because RSA PKCS#1v1.5 signing has no random padding: the SAME +(input XML, private key) pair always produces the SAME signature bytes -- +this is the byte-exact assertion `tests/test_signed_goldens.py` checks. + + uv run python scripts/generate_signed_goldens.py +""" +import importlib.resources +from pathlib import Path + +from cryptography.hazmat.primitives.serialization import pkcs12 +from sowai_fiscal.golden_helpers import dados_from_json +from sowai_fiscal.xml_builder import build_nfe + +from fiscal_svc.emission.service import _serialize_nfe, sign_nfe_xml + +REPO_DIR = Path(__file__).resolve().parents[1] +CERT_PATH = REPO_DIR / "tests" / "fixtures" / "test_cert.pfx" +CERT_PASSWORD = "test-cert-password" # must match generate_test_cert.py +OUT_DIR = REPO_DIR / "tests" / "goldens_signed" + + +def main() -> None: + if not CERT_PATH.exists(): + raise SystemExit( + f"{CERT_PATH} não existe -- rode scripts/generate_test_cert.py primeiro" + ) + pfx_bytes = CERT_PATH.read_bytes() + private_key, cert, _ca_certs = pkcs12.load_key_and_certificates( + pfx_bytes, CERT_PASSWORD.encode("utf-8") + ) + OUT_DIR.mkdir(parents=True, exist_ok=True) + + goldens_dir = Path(str(importlib.resources.files("sowai_fiscal") / "goldens")) + for input_path in sorted(goldens_dir.glob("*.input.json")): + case = input_path.name.removesuffix(".input.json") + dados = dados_from_json(input_path) + nfe = build_nfe(dados) + xml_str = _serialize_nfe(nfe) + xml_assinado = sign_nfe_xml(xml_str, dados.chave_acesso, private_key, cert) + out_path = OUT_DIR / f"{case}.expected.xml" + out_path.write_text(xml_assinado, encoding="utf-8") + print(f"wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_test_cert.py b/scripts/generate_test_cert.py new file mode 100644 index 0000000..9684b88 --- /dev/null +++ b/scripts/generate_test_cert.py @@ -0,0 +1,67 @@ +"""Task 6: one-shot generator for `tests/fixtures/test_cert.pfx` -- a +self-signed TEST-ONLY A1 certificate, NEVER a real one (design spec's "os 3 +seguros de portabilidade", item (b)/(c) extended by the F2 emenda: goldens +ASSINADOS need a FIXED test certificate, committed as a binary fixture). + +Re-running this script produces a DIFFERENT RSA key pair (key generation is +not seeded) -- that is fine and expected: this is meant to be run ONCE and +the output COMMITTED. The determinism `tests/test_signed_goldens.py` relies +on comes from every test run reusing the SAME committed `.pfx` file (so the +same private key signs the same canonicalized XML the same way every time, +RSA PKCS#1v1.5 having no random padding) -- NOT from this generator being +reproducible bit-for-bit across runs. Re-run only if the fixture needs to be +rotated, and regenerate `tests/goldens_signed/*.expected.xml` +(`scripts/generate_signed_goldens.py`) together with it, in the same commit. + + uv run python scripts/generate_test_cert.py +""" +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID + +# Mesmo CNPJ do emitente usado em TODOS os casos golden da lib +# (`sowai_fiscal.goldens/*.input.json`) -- ver esse pacote se algum caso +# novo usar um CNPJ diferente; este script/fixture precisaria acompanhar. +CNPJ = "12345678000190" +PASSWORD = "test-cert-password" +OUT_PATH = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "test_cert.pfx" + + +def main() -> None: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, f"AUTOPECAS THIAGO LTDA:{CNPJ}"), + x509.NameAttribute(NameOID.SERIAL_NUMBER, CNPJ), + ] + ) + now = datetime.now(timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=3650)) + .sign(key, hashes.SHA256()) + ) + pfx_bytes = pkcs12.serialize_key_and_certificates( + name=b"sowai-fiscal-svc-test", + key=key, + cert=cert, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(PASSWORD.encode("utf-8")), + ) + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUT_PATH.write_bytes(pfx_bytes) + print(f"wrote {OUT_PATH} (cnpj={CNPJ}, password={PASSWORD!r})") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/test_cert.pfx b/tests/fixtures/test_cert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..5c681532893c7fc5f73bc9b83edc403c6d1f787e GIT binary patch literal 2686 zcmai$X*ksH8pmhG41)>TB1`tH)6bSY6^0Pm*JSKFA-ln3tVLwaTDGz@Mz+G(l0?>+ zC?v~dOC}jxInH&?)&IOZFYe|0z3=CG-hHkcz%oQYz%&4s0idOqOwdc9o&wW>OR)@s zP%H!AiOmgQq5A)fXiKqBwG>0tTIw|9u49`nv`@1(*V||40NN0%ZwsU9lSve?ey{ zgJ(==`{4?K7zqFfTKIo{h0w!503R*AV}c&ol?DoygtFxCYM&CGLV28Xc5b8y@VLcd zX}VU*W`QwHz0O6=(Few)`R*?-;R@LDhZ)L}Yw_4818yjjgL>r*?bPzxWHDl^O(r7y zdiv$1a^mF{x@c7k5}lEBoRx!k?=H{oy|1Hav4;~C%_J(ptZ;*BE(P8H=bae@K@p)jq9_`QFVrsQN;3NLN0=M>?$IIBOhA zk>MFk;V4}F9IhjG)K_!vx9Gj{Y=2lb|152Fdox#mSYa<%&fdy7fEu`G4zcsfz5N<& zc>G{XB$ePZ*ry7{rD)Jq3`eA5%#+l2$b+QA8TE7oZg_0ghs+y85Y)@yfWGo&E`QCC zb`#%X$~_vZj~(H6uRM^uVS+& zyf)Er?J!~StEweW zn0+oEb>u;`#J+z-z@Fz4P9xi*4%10L3VsTQ0Je)pxf|PxwHw^8Sdo3#1dw=Egspcg zp9v6VEg!EIB}Y*}IYPW=ly)=6pd&Sx3{`x2|DX@l3;TGz9HMp2Ks4%E>)o066t90N zh54^hX+)>H`0?ClH}2&lHj~?s#P>vG&z9qtbBiLFW|Jgh_G7A0{8>Z#0Qa0*yvR$h zmZOVZI7T4bUV>v=Zq3Lk;y3E@YoS07TRV^OAa z2v~S2i!&X{Fi?i=s76n>6HeDsE1u#ob~;#7_Mp&TLF^|)KY_lmRH;MZuk)Z3+w;Og@1zv)(XYK8c*!{lc=R-{4awL3}DE~ zQ02r_{C{AXyV=g{aM;B)RHm`~=9yyj{{_p!9uCTC`%HL9fwAr3hQBiu3msL}o`HYv zmFXO|LB+H4&%&w)+suDDGs(XRY_GSZl%6Ufh+}#(Tq4!qrUT85t1&>HIC!hmR}(mX`?!D4KFD- z&Xv<%b-XW6i8N!=<3ypf7B3E*UNnm;en8G-OGK4S&cxrO4)$&4772f`m$)yFfEC>= zo~%)fcvB@GkuQeW&l|Aww2yhL+ZAFylCk8T+nN!Ul>9)WXNRk-OgnYr!!g6v2(y*R zyc>yBn)=WL3oq~4ykcysR-tf|&?~0Ay+@j!O5q=W8wgjb;R-xhzScO95iB#}3sPm5 zJ*!q#GOv|CAHv&lX*mB`11Z7}DrLXyHIRJx77<^{wj#Y>*C3`Ek=1<%A{5>}U_23l2@u(;DwU zu^i;?jL4)-8BtrHqt=$U=Jyd?%Y4L?{8Kk=->7m+&cM=Mf^hpfuf57MQ`GIW8L+_h zd5BUcPb-d?Ri`89UeY^E7%E;A-T5X{=iM=Obc9WB+PN=h%#J&fvXoa5rH)*k4=BZ0 zT*EA}_9hD(FVfzMrW(>Bm+D{Hst5$$na&BOunpFO=B?{-QcQM%BUQ$ZeRsnn%_f^V zFEBe!QyS*3D+Ky9vc1HHXO671^YKkKa8zfgE9RVF_?TCnr+k6FX>Icf0Dwuk( zP3?-M%NIbnF!$nWJBdE|hZ%i$#+XO~@yYxlsuY`CO}^OvSUxq4*T#AR&q0 zauMhv9S!wLw*rNQs4p#NHS=exwmikpIE`;O2>Zk!pdY^9mb+_Fkg<-ZUHcYU`-10d zUGduM%s=YSZr)K; z`@YT@_`qCYcL9{VCuG8sD)^(jt`ljeSE?mC7FyqUhPu=?-5OV7+f}rdD08JBq&#)E zD=cov8{6YFoZ{|9*DG$*kFVcQXDCnKX123(^ien3U4`aLCIRHfckVu`ZZeA2Oh|31kEq9 zfNF}e$HF+x1LQ1X7wGT^vSEJ5qcVeuy( NfAFb&90GxZ{sk18%pw2) literal 0 HcmV?d00001 diff --git a/tests/goldens_signed/caso_com_ub.expected.xml b/tests/goldens_signed/caso_com_ub.expected.xml new file mode 100644 index 0000000..67257fb --- /dev/null +++ b/tests/goldens_signed/caso_com_ub.expected.xml @@ -0,0 +1,18 @@ +4110000005Venda55110052026-07-16T10:20:00-03:0011410690211021110sowai-auto/1b.112345678000190AUTOPECAS THIAGO LTDAThiago Auto CenterRua das Autopecas1500Centro4106902CuritibaPR800000001058Brasil41333344441234567890199887766000155NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCALAv Cliente50Bairro4106902CuritibaPR800001001058Brasil9PC001SEM GTINFiltro de oleo842123005102UN1100.00100.00SEM GTINUN1100.00101020808000000001100.000.100010.000.900090.000.000.000.000.000.000.000.000.00100.000.000.000.000.000.000.000.000.000.00100.009001100.00EIBfqHSPgzGamG7niiWJY8QjvaY=lMywqdUdSFsbf5SR1kOmyByaFI1aSoUJwwgbz+P7Qri6iW+pJy+JJcapJnQcEdYbbFTqMJYJeRM87xHlQngg0SnPqd1ZjcLqZgXf6tm7GZriS3SNaMiXUC5EG9BnawvP4p7m5GviX3JJNKs2lvG0unBUtptq4aH/cZe5jd62SxeqyaeX4PxW/pbvkSBu3WndTLvM56vajMgimjbjQ8zLid85XJkVEAk9rV7KObZsHl0vuvFUqnZsAVaIJxB8nmmmcn/clT4AsPzhlkozR5YV0OjpP4sm08bYcvPcGcxxidxHE1o40xr6S6ozpiQENPEUBgTpNqMYaJTI0Rft3ERWaA==MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL +BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw +MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z +NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox +MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk +oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR +uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld +ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul +gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC +vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ +KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4 +7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab +ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+ +BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA +wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz +lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ= + \ No newline at end of file diff --git a/tests/goldens_signed/caso_devolucao_intra.expected.xml b/tests/goldens_signed/caso_devolucao_intra.expected.xml new file mode 100644 index 0000000..ae21a3d --- /dev/null +++ b/tests/goldens_signed/caso_devolucao_intra.expected.xml @@ -0,0 +1,18 @@ +4110000004Devolucao de venda55110042026-07-16T10:15:00-03:0011410690211521110sowai-auto/1b.112345678000190AUTOPECAS THIAGO LTDAThiago Auto CenterRua das Autopecas1500Centro4106902CuritibaPR800000001058Brasil41333344441234567890199887766000155NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCALAv Cliente50Bairro4106902CuritibaPR800001001058Brasil9PC001SEM GTINFiltro de oleo842123001202UN175.0075.00SEM GTINUN175.0010102080875.000.000.000.000.000.000.000.0075.000.000.000.000.000.000.000.000.000.0075.00909075.00dWH2Qpht8rN1pncCUL31+ymheWE=iyBjOX1COZz2vFk1SNaQvmLqvsXGo63DINjVvAsoAA2g6XsnoLdrNjXOtxATh4Iez1SEcVoOex5V6x4/Vdd7EkX8TSu5QKqIAbDUjystojZAVW+wXj7enUgb24qbrsFU40EhEPUdZp51grKgm0d2R4qNeodXZiFGbuheRZQ4EhY5b+2EPX6kSdxGuYi4F0Xq0JgkG90CTJIUFNEDkY2PKCXOipNBpx6nOYiWnEBGfMw2oTmJeXQ2yKyXjrr01S9Lj48UZybGMIStQJoMTHn+p9+3ccTwbBAJ66Et38SEsP8gmWaLkGq5+zMCtriiwEJzCJMzTUMnDPee+/nada0KWg==MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL +BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw +MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z +NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox +MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk +oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR +uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld +ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul +gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC +vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ +KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4 +7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab +ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+ +BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA +wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz +lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ= + \ No newline at end of file diff --git a/tests/goldens_signed/caso_fracao_centavo.expected.xml b/tests/goldens_signed/caso_fracao_centavo.expected.xml new file mode 100644 index 0000000..27e96e9 --- /dev/null +++ b/tests/goldens_signed/caso_fracao_centavo.expected.xml @@ -0,0 +1,18 @@ +4110000006Venda55110062026-07-16T10:25:00-03:0011410690211621110sowai-auto/1b.112345678000190AUTOPECAS THIAGO LTDAThiago Auto CenterRua das Autopecas1500Centro4106902CuritibaPR800000001058Brasil41333344441234567890199887766000155NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCALAv Cliente50Bairro4106902CuritibaPR800001001058Brasil9PC900SEM GTINArruela avulsa731819005102UN1.50.010.02SEM GTINUN1.50.01101020808PC901SEM GTINArruela avulsa 2731819005102UN1.50.010.02SEM GTINUN1.50.011010208080.040.000.000.000.000.000.000.000.040.000.000.000.000.000.000.000.000.000.0490010.04UrxPctSfvF8tf9cYULFIdWbEUMA=Bdi9HkxrMIPD7dtfd8wq1Mg2hCt+ZFffU3n79WqAxcDDLgtjCOb/vX4hvXj+J+LZ+SEcNzKFLF5kRyriHb7+e9YkS7BFgljNOGZunqkTz6/ZdmrPNlZB89z6SOB5aI+Z+J2KHtOO4l7kyIji5EdJl+NrDfRoCpBFFctBVMKCKtwBh31KoNDWq0XzqmfI+FDfZskdgR+W0AGFg6pGbNfv4dhpOAPT5/UY8HQDU4Zv6hVleUv/ifsqKTKzEa7z9/IL+5894NoPk9JyhNAFgKQDs4y2r1irykOsu1LHH+BvnPPCGKeKCLsVxqTgQxseKkfhiis2hTt97e365a7UmZ1JaA==MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL +BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw +MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z +NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox +MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk +oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR +uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld +ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul +gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC +vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ +KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4 +7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab +ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+ +BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA +wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz +lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ= + \ No newline at end of file diff --git a/tests/goldens_signed/caso_padrao_inter.expected.xml b/tests/goldens_signed/caso_padrao_inter.expected.xml new file mode 100644 index 0000000..c496ec2 --- /dev/null +++ b/tests/goldens_signed/caso_padrao_inter.expected.xml @@ -0,0 +1,18 @@ +4110000002Venda55110022026-07-16T10:05:00-03:0012410690211421110sowai-auto/1b.112345678000190AUTOPECAS THIAGO LTDAThiago Auto CenterRua das Autopecas1500Centro4106902CuritibaPR800000001058Brasil41333344441234567890111222333000144NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCALAv Paulista1000Sala 10Bela Vista3550308Sao PauloSP013100001058Brasil11122334455compras@clienteinter.com.brPC001SEM GTINFiltro de oleo842123006102UN1075.00750.00SEM GTINUN1075.00101020808750.000.000.000.000.000.000.000.00750.000.000.000.000.000.000.000.000.000.00750.009017750.00wMN3wf/wyPYVP/p8oSMeLIwQ0sI=YTzAGw2xRWQSgMsJuF1XB2elWaTAHRRnLvOwM3Bo6YHNGVM3AqViOlhf0jRmbuQe4VCj8ugSEAe24p50Mn0YGLqH91emub4ADsewNAjeIcb7C9n/cYk8Sh2e/wPc3DvS5cOSICUOroPak0A3bWFb8DjRAcUdYvX/uI0IrzMlN0Qxzjd+lZwo/1cmzIWnW0G+nDvd2rykGOZTwCxd+yZf+qHw+A1iLFgeI9YHyWcWoUtKH746GvlJQ59Oz4dTXgtJL3Nnhx2O7dsgbU0/ee8Q6bYGELZz6Jtdup3EOVI7T5K/BBgVeNpchQfj7hHdL1gzmVAnGdVnBR01EDQnscVtZw==MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL +BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw +MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z +NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox +MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk +oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR +uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld +ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul +gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC +vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ +KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4 +7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab +ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+ +BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA +wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz +lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ= + \ No newline at end of file diff --git a/tests/goldens_signed/caso_padrao_intra.expected.xml b/tests/goldens_signed/caso_padrao_intra.expected.xml new file mode 100644 index 0000000..1ac7e9b --- /dev/null +++ b/tests/goldens_signed/caso_padrao_intra.expected.xml @@ -0,0 +1,18 @@ +4110000001Venda55110012026-07-16T10:00:00-03:0011410690211921110sowai-auto/1b.112345678000190AUTOPECAS THIAGO LTDAThiago Auto CenterRua das Autopecas1500Centro4106902CuritibaPR800000001058Brasil41333344441234567890199887766000155NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCALAv Cliente50Bairro4106902CuritibaPR800001001058Brasil9PC001SEM GTINFiltro de oleo842123005102UN275.00150.00SEM GTINUN275.00101020808PC002SEM GTINPastilha de freio870830905102PC445.50182.00SEM GTINPC445.50101020808332.000.000.000.000.000.000.000.00332.000.000.000.000.000.000.000.000.000.00332.009001332.001inHNlmRb0maeftKaq/JExhNc8A=ked+Fn1wxCy5EdNT289eLof7cqwuWvj1bh/AV0CQ3zbrLD5kL5/fXj2pLMZWS9wyl3NG8ZFATJ5B1xmeDcc0Niigs9O+e9wwWgX+3STDHZOogLuOU/CKhgFq/6e4wkZlMhV7Yffo+swo5qf82itGxUkkQkD00TDuFP7OnKOZBW8Ti+CPPPm+HLv613+BolP14If/2BXJW9HZiSu4Cua66/E32RSqPSp319DPvvpGwefJWDddOYfK9TkChirgdygAQUrkeJutcQdzzo9LnSVKE3FAu6q5w3rkNIrqA0OTEctGpojwfXc9E+IzRehof6fPvDCUjwSe89fP4JpK6tYiYA==MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL +BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw +MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z +NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox +MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk +oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR +uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld +ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul +gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC +vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ +KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4 +7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab +ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+ +BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA +wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz +lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ= + \ No newline at end of file diff --git a/tests/goldens_signed/caso_st_intra.expected.xml b/tests/goldens_signed/caso_st_intra.expected.xml new file mode 100644 index 0000000..5a30aa4 --- /dev/null +++ b/tests/goldens_signed/caso_st_intra.expected.xml @@ -0,0 +1,18 @@ +4110000003Venda55110032026-07-16T10:10:00-03:0011410690211021110sowai-auto/1b.112345678000190AUTOPECAS THIAGO LTDAThiago Auto CenterRua das Autopecas1500Centro4106902CuritibaPR800000001058Brasil41333344441234567890112345678909NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCALRua Oficina22Industrial4106902CuritibaPR800002001058Brasil9OL500SEM GTINOleo lubrificante 15W40 1L2710125906001005405UN1100.00100.00SEM GTINUN1100.0010500140.0018.000013.2004040.000.000.000.00140.0013.200.000.00100.000.000.000.000.000.000.000.000.000.00100.009001100.00GynTwfOg2ofaLE8WyWWwdpLBjWg=1KMXWaK4qME2/kgazsMvEIB0B2Oxr6bLCNtfa6IrWd/i+muv9CDRhOm/yKumdRdmC6wys0o0krkwOW0Zh91iCikvHVyO/sh4ze4ycFzE2cODFpEciS7+59pMZM4vARhoZga855QKTzCB6CvFx99VAeq7X0wxXc7FWEmz2ENH6Vn0pkFkiMlpIgT1yFtGZbOQ5lcuE0hGTQJlEool/dbZRWWNp2CPbpx4Hys/CwLUM/MIA7lgfOqvEE0VxrfsU4Cfk2azBylFmprGnySot6HMPl0nkmGoVWEnaX3hxIlJJjfHXPZMnRp/HeryZpNb5GRzWjW9oJlXZUkzPcc02gkI8g==MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL +BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw +MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z +NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox +MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk +oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR +uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld +ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul +gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC +vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ +KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4 +7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab +ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+ +BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA +wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz +lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ= + \ No newline at end of file diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..0017d9d --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,400 @@ +"""Task 6: suíte de CONTRATO HTTP -- design spec's "os 3 seguros de +portabilidade", item (a): "suíte de contrato HTTP language-agnostic (bate na +API, não no interior)". A REGRA DESTE ARQUIVO: zero import de `fiscal_svc.*` +além de `fiscal_svc.main.app` (para montar o `ASGITransport` -- inevitável +em Python; um cliente HTTP real bateria numa URL, não precisaria nem disso). +Nenhum import de model/service/schema interno -- é a suíte que uma +implementação GO deste MESMO contrato (`docs/openapi-v1.json`) teria que +passar batendo na porta 8140 de fora, sem NUNCA ver este código-fonte. + +Provisionamento de fixtures (criar um Product/API key) é feito via +`scripts/create_product.py`'s MESMA função (`tenancy.service.create_ +product`) em qualquer OUTRO arquivo de teste deste repo -- mas aqui, para +manter a regra "zero import do interior", cada teste usa o endpoint HTTP +disponível (upload de certificado, criação de série) e trata a criação do +Product como um pré-requisito de infraestrutura resolvido pela fixture +`product_api_key` (que É a única concessão: sem UM jeito de provisionar um +Product por HTTP -- design spec decisão #5, "provisionar um Product é uma +ação de operador, não self-service" -- não há como popular um cenário sem +tocar a camada de app UMA vez por teste; a asserção em si nunca o faz).""" +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +import pytest_asyncio +from cryptography import x509 +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID +from httpx import ASGITransport, AsyncClient + +from fiscal_svc.core.db import get_session +from fiscal_svc.main import app + +_CNPJ = "14200166000187" + + +@pytest.fixture(autouse=True) +def _override_db(db_session): + async def _get_session_override(): + yield db_session + + app.dependency_overrides[get_session] = _get_session_override + yield + app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True) +def _fiscal_cert_encryption_key(monkeypatch): + monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii")) + yield + + +@pytest_asyncio.fixture +async def product_api_key(db_session): + """A ÚNICA concessão à regra "zero import do interior" -- provisionar + um Product não tem endpoint HTTP (decisão de design deliberada, ver o + docstring do módulo), então esta fixture chama a função de serviço + diretamente. Nenhum teste abaixo importa nada além disto.""" + from fiscal_svc.tenancy.service import create_product + + key = f"k-{uuid.uuid4().hex}" + await create_product(db_session, name=f"contract-{uuid.uuid4().hex[:8]}", api_key=key) + return key + + +def _headers(api_key: str) -> dict[str, str]: + return {"X-Api-Key": api_key} + + +def _build_test_pfx(cnpj: str = _CNPJ, password: str = "senha123") -> bytes: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, f"EMPRESA TESTE LTDA:{cnpj}"), + x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj), + ] + ) + now = datetime.now(timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=365)) + .sign(key, hashes.SHA256()) + ) + return pkcs12.serialize_key_and_certificates( + name=b"test", key=key, cert=cert, cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(password.encode("utf-8")), + ) + + +# --- health ------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_health_returns_200_ok_shape(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/v1/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +# --- auth (require_product) -------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method,path,kwargs", + [ + ("get", "/v1/certificados", {"params": {"tenant_ref": "t", "branch_ref": "b"}}), + ("get", "/v1/series", {"params": {"tenant_ref": "t", "branch_ref": "b"}}), + ("get", "/v1/documentos", {}), + ("post", "/v1/series", {"json": {"tenant_ref": "t", "branch_ref": "b", "document_model": "55", "serie": 1, "next_number": 1}}), + ], +) +async def test_every_v1_route_requires_api_key(method, path, kwargs): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await getattr(client, method)(path, **kwargs) + + assert response.status_code == 401, response.text + + +@pytest.mark.asyncio +async def test_wrong_api_key_is_401(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/v1/series", + params={"tenant_ref": "t", "branch_ref": "b"}, + headers=_headers("not-a-real-key"), + ) + + assert response.status_code == 401, response.text + + +# --- series -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_series_create_list_patch_shapes(product_api_key): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + create_response = await client.post( + "/v1/series", + json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1}, + headers=_headers(product_api_key), + ) + assert create_response.status_code == 201, create_response.text + body = create_response.json() + for field in ("id", "product_id", "tenant_ref", "branch_ref", "document_model", "serie", "next_number"): + assert field in body, f"campo {field!r} ausente no shape de FiscalSeriesRead" + series_id = body["id"] + + duplicate_response = await client.post( + "/v1/series", + json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 2}, + headers=_headers(product_api_key), + ) + assert duplicate_response.status_code == 409, duplicate_response.text + assert duplicate_response.json()["detail"]["code"] == "duplicate_fiscal_series" + + list_response = await client.get( + "/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key) + ) + assert list_response.status_code == 200, list_response.text + assert isinstance(list_response.json(), list) + assert len(list_response.json()) == 1 + + patch_response = await client.patch( + f"/v1/series/{series_id}", json={"next_number": 5}, headers=_headers(product_api_key) + ) + assert patch_response.status_code == 200, patch_response.text + assert patch_response.json()["next_number"] == 5 + + not_found_response = await client.patch( + f"/v1/series/{uuid.uuid4()}", json={"next_number": 5}, headers=_headers(product_api_key) + ) + assert not_found_response.status_code == 404, not_found_response.text + + +# --- certificados ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_certificados_upload_get_delete_shapes(product_api_key): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + upload_response = await client.post( + "/v1/certificados", + params={"tenant_ref": "t1", "branch_ref": "b1"}, + files={"file": ("cert.pfx", _build_test_pfx(), "application/x-pkcs12")}, + data={"password": "senha123", "cnpj": _CNPJ}, + headers=_headers(product_api_key), + ) + assert upload_response.status_code == 201, upload_response.text + body = upload_response.json() + for field in ( + "id", "product_id", "tenant_ref", "branch_ref", "cnpj", + "subject_cn", "cnpj_certificado", "not_valid_before", "not_valid_after", "created_at", + ): + assert field in body, f"campo {field!r} ausente no shape de FiscalCertificateRead" + assert "pfx_encrypted" not in body + assert "password_encrypted" not in body + + get_response = await client.get( + "/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key) + ) + assert get_response.status_code == 200, get_response.text + + delete_response = await client.delete( + "/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key) + ) + assert delete_response.status_code == 204, delete_response.text + + get_after_delete_response = await client.get( + "/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key) + ) + assert get_after_delete_response.status_code == 404, get_after_delete_response.text + + +@pytest.mark.asyncio +async def test_certificado_invalido_e_422(product_api_key): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/v1/certificados", + params={"tenant_ref": "t1", "branch_ref": "b1"}, + files={"file": ("cert.pfx", b"nao e um pfx", "application/x-pkcs12")}, + data={"password": "qualquer", "cnpj": _CNPJ}, + headers=_headers(product_api_key), + ) + + assert response.status_code == 422, response.text + + +# --- emissão --------------------------------------------------------------- + + +def _minimal_emissao_payload(*, tenant_ref="t1", branch_ref="b1", serie=1) -> dict: + return { + "tenant_ref": tenant_ref, + "branch_ref": branch_ref, + "document_model": "55", + "serie": serie, + "emitente": { + "cnpj": _CNPJ, + "razao_social": "EMPRESA TESTE LTDA", + "nome_fantasia": None, + "ie": "1234567890", + "crt": "1", + "address_street": "Rua Teste", + "address_number": "100", + "address_complement": None, + "address_district": "Centro", + "address_city": "Curitiba", + "address_state": "PR", + "address_zip": "80000000", + "address_city_ibge_code": "4106902", + }, + "itens": [ + { + "codigo": "PC001", + "descricao": "Peca teste", + "ncm": "84212300", + "cfop": "5102", + "unidade_comercial": "UN", + "unidade_tributavel": "UN", + "quantidade": "1", + "valor_unitario": "10.00", + "fiscal_result": { + "cfop": "5102", + "cst": None, + "csosn": "102", + "origem": "0", + "consumidor_final": True, + "indicador_ie": "9", + "tributos": [], + }, + } + ], + "pagamento": {"tpag": "01", "valor": "10.00", "indpag": "0"}, + "ambiente": "homologacao", + "uf_destino_tipo": "interna", + "destinatario": None, + "ver_proc": "contract-test/1.0", + } + + +@pytest.mark.asyncio +async def test_emissao_requires_idempotency_key_header(product_api_key): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/v1/emissoes", json=_minimal_emissao_payload(), headers=_headers(product_api_key) + ) + + assert response.status_code == 422, response.text + + +@pytest.mark.asyncio +async def test_emissao_requires_ver_proc(product_api_key): + payload = _minimal_emissao_payload() + del payload["ver_proc"] + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/v1/emissoes", json=payload, + headers={**_headers(product_api_key), "Idempotency-Key": f"k-{uuid.uuid4().hex}"}, + ) + + assert response.status_code == 422, response.text + + +@pytest.mark.asyncio +async def test_emissao_without_certificate_or_series_is_409_fiscal_config_missing(product_api_key): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/v1/emissoes", json=_minimal_emissao_payload(), + headers={**_headers(product_api_key), "Idempotency-Key": f"k-{uuid.uuid4().hex}"}, + ) + + assert response.status_code == 409, response.text + body = response.json() + assert body["detail"]["code"] == "fiscal_config_missing" + assert "message" in body["detail"] + + +@pytest.mark.asyncio +async def test_full_emissao_happy_path_shapes_and_idempotency(product_api_key): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + upload_response = await client.post( + "/v1/certificados", + params={"tenant_ref": "t1", "branch_ref": "b1"}, + files={"file": ("cert.pfx", _build_test_pfx(), "application/x-pkcs12")}, + data={"password": "senha123", "cnpj": _CNPJ}, + headers=_headers(product_api_key), + ) + assert upload_response.status_code == 201, upload_response.text + + series_response = await client.post( + "/v1/series", + json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1}, + headers=_headers(product_api_key), + ) + assert series_response.status_code == 201, series_response.text + + idem_key = f"k-{uuid.uuid4().hex}" + first_response = await client.post( + "/v1/emissoes", json=_minimal_emissao_payload(), + headers={**_headers(product_api_key), "Idempotency-Key": idem_key}, + ) + assert first_response.status_code == 201, first_response.text + body = first_response.json() + for field in ( + "id", "product_id", "tenant_ref", "branch_ref", "series_id", "document_model", + "serie", "numero", "chave_acesso", "codigo_numerico", "status", "ambiente", + "rejeicao_codigo", "rejeicao_motivo", "protocolo", "autorizada_em", "created_at", + ): + assert field in body, f"campo {field!r} ausente no shape de FiscalDocumentRead" + assert "xml_assinado" not in body + assert body["status"] == "ASSINADO" + document_id = body["id"] + + second_response = await client.post( + "/v1/emissoes", json=_minimal_emissao_payload(), + headers={**_headers(product_api_key), "Idempotency-Key": idem_key}, + ) + assert second_response.status_code == 200, second_response.text + assert second_response.json()["id"] == document_id + + get_response = await client.get(f"/v1/documentos/{document_id}", headers=_headers(product_api_key)) + assert get_response.status_code == 200, get_response.text + assert "xml_assinado" not in get_response.json() + + xml_response = await client.get(f"/v1/documentos/{document_id}/xml", headers=_headers(product_api_key)) + assert xml_response.status_code == 200 + assert xml_response.headers["content-type"].startswith("application/xml") + assert ".expected.xml` (gerado uma vez por +`scripts/generate_signed_goldens.py`). + +RSA PKCS#1v1.5 (o que `erpbrasil.assinatura` usa) não tem padding +aleatório -- a MESMA chave privada assinando o MESMO XML canonicalizado +produz sempre a MESMA assinatura. Isso é o que torna esta comparação byte a +byte estável entre execuções (provado por `scripts/generate_signed_goldens. +py` sendo rodado duas vezes seguidas e comparado -- ver seu commit).""" +import importlib.resources +import json +import uuid +from datetime import datetime +from pathlib import Path + +import pytest +from httpx import ASGITransport, AsyncClient + +from fiscal_svc.core.db import get_session +from fiscal_svc.emission import service as emission_service +from fiscal_svc.main import app +from fiscal_svc.tenancy.service import create_product + +_GOLDENS_DIR = Path(str(importlib.resources.files("sowai_fiscal") / "goldens")) +_EXPECTED_DIR = Path(__file__).resolve().parent / "goldens_signed" +_CERT_PATH = Path(__file__).resolve().parent / "fixtures" / "test_cert.pfx" +_CERT_PASSWORD = "test-cert-password" + +_CASES = sorted(p.name.removesuffix(".input.json") for p in _GOLDENS_DIR.glob("*.input.json")) + + +@pytest.fixture(autouse=True) +def _override_db(db_session): + async def _get_session_override(): + yield db_session + + app.dependency_overrides[get_session] = _get_session_override + yield + app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True) +def _fiscal_cert_encryption_key(monkeypatch): + from cryptography.fernet import Fernet + + from fiscal_svc.certificates import crypto as certificate_lib + + monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii")) + certificate_lib._get_fernet.cache_clear() + yield + certificate_lib._get_fernet.cache_clear() + + +def _headers(api_key: str) -> dict[str, str]: + return {"X-Api-Key": api_key} + + +class _FrozenDatetime: + """Stand-in for `emission.service`'s module-level `datetime` NAME + (`from datetime import datetime` -- a plain attribute of the module, + monkeypatch-replaceable). Exposes only `.now(tz)`, the ONLY method + `emission.service` calls on it -- returns the SAME fixed instant + regardless of the requested `tz` (converted via `.astimezone`), so both + call sites (`datetime.now(_TZ_EMISSAO)` for `dh_emi`/AAMM and `datetime. + now(timezone.utc)` for the certificate validity check) see a coherent, + pinned "now".""" + + def __init__(self, fixed: datetime): + self._fixed = fixed + + def now(self, tz=None): + return self._fixed.astimezone(tz) if tz is not None else self._fixed + + +@pytest.mark.parametrize("case", _CASES) +@pytest.mark.asyncio +async def test_signed_golden_matches_byte_for_byte(db_session, monkeypatch, case): + raw = json.loads((_GOLDENS_DIR / f"{case}.input.json").read_text(encoding="utf-8")) + expected = (_EXPECTED_DIR / f"{case}.expected.xml").read_text(encoding="utf-8") + + # cNF pinado -- o único elemento aleatório de `emitir_documento` além do + # relógio. + monkeypatch.setattr(emission_service, "gerar_cnf", lambda numero: raw["cnf"]) + # dh_emi/AAMM pinados no MESMO instante do golden. + fixed_now = datetime.fromisoformat(raw["dh_emi"]) + monkeypatch.setattr(emission_service, "datetime", _FrozenDatetime(fixed_now)) + + key = f"k-{uuid.uuid4().hex}" + product = await create_product(db_session, name=f"golden-{case}", api_key=key) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + pfx_bytes = _CERT_PATH.read_bytes() + upload_response = await client.post( + "/v1/certificados", + params={"tenant_ref": "t1", "branch_ref": "b1"}, + files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")}, + data={"password": _CERT_PASSWORD, "cnpj": raw["emitente"]["cnpj"]}, + headers=_headers(key), + ) + assert upload_response.status_code == 201, upload_response.text + + # `numero` é controlado plantando o `next_number` da série -- a + # primeira (e única) alocação desta série devolve exatamente + # `raw["numero"]`. + series_response = await client.post( + "/v1/series", + json={ + "tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", + "serie": raw["serie"], "next_number": raw["numero"], + }, + headers=_headers(key), + ) + assert series_response.status_code == 201, series_response.text + + payload = { + "tenant_ref": "t1", + "branch_ref": "b1", + "document_model": "55", + "serie": raw["serie"], + "emitente": raw["emitente"], + "itens": raw["itens"], + "pagamento": raw["pagamento"], + "ambiente": raw["ambiente"], + "uf_destino_tipo": raw["uf_destino_tipo"], + "destinatario": raw.get("destinatario"), + # A lib congela `ver_proc="sowai-auto/1b.1"` como default do + # PRÓPRIO `DadosEmissao` quando o golden JSON não o especifica + # (todos os 6 casos hoje) -- reproduzido aqui EXPLICITAMENTE + # (design spec EMENDA F1: este serviço nunca aplica esse default + # sozinho, o produto chamador sempre declara). + "ver_proc": raw.get("ver_proc", "sowai-auto/1b.1"), + } + emit_response = await client.post( + "/v1/emissoes", json=payload, + headers={**_headers(key), "Idempotency-Key": f"golden-{case}-{uuid.uuid4().hex}"}, + ) + assert emit_response.status_code == 201, emit_response.text + document_id = emit_response.json()["id"] + + xml_response = await client.get(f"/v1/documentos/{document_id}/xml", headers=_headers(key)) + + assert emit_response.json()["chave_acesso"] == raw["chave_acesso"], ( + "a chave de acesso montada pelo serviço deveria bater com a do golden -- todos os " + "componentes (uf_ibge/aamm/cnpj/modelo/serie/numero/tp_emis/cnf) precisam coincidir" + ) + assert xml_response.text == expected, ( + f"XML assinado do caso {case!r} divergiu byte a byte do golden esperado -- " + "regenere com scripts/generate_signed_goldens.py SE E SOMENTE SE a mudança for " + "intencional (ex.: golden novo/atualizado na lib), nunca para 'fazer passar'" + )