feat: certificates + series API v1 (Task 4)

Port certificate lifecycle (parse/encrypt/upload/deactivate) and
FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref,
branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series
next_number regression check that Task 3 deferred to this task.

Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
This commit is contained in:
jonatanritter
2026-07-22 16:51:22 -03:00
parent 836c267e09
commit c903a9ce0e
17 changed files with 1515 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
"""`POST/GET/DELETE /v1/certificados` -- Task 4. `tenant_ref`/`branch_ref`
travel as QUERY params on all three verbs (consistent shape across POST/GET/
DELETE, since POST's body is multipart -- file + form fields -- and cannot
also carry a JSON body); `cnpj`/`password` are `Form(...)` fields alongside
the file, same multipart shape as the auto's own certificate upload."""
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from fiscal_svc.certificates import crypto as certificate_lib
from fiscal_svc.certificates import service
from fiscal_svc.certificates.schemas import FiscalCertificateRead
from fiscal_svc.core.db import get_session
from fiscal_svc.shared.errors import conflict
from fiscal_svc.tenancy.deps import require_product
from fiscal_svc.tenancy.models import Product
router = APIRouter(prefix="/v1/certificados", tags=["certificados"])
# Teto de tamanho do upload do .pfx -- mesmo valor/racional do auto
# (`fiscal.router._MAX_PFX_UPLOAD_BYTES`): um A1 típico fica na casa de
# poucos KB; 256 KiB é generoso o bastante sem deixar o endpoint aceitar um
# upload arbitrariamente grande.
_MAX_PFX_UPLOAD_BYTES = 256 * 1024
async def _read_upload_capped(file: UploadFile, max_bytes: int) -> bytes:
chunk_size = 64 * 1024
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=f"Certificado excede o tamanho máximo permitido ({max_bytes} bytes)",
)
chunks.append(chunk)
return b"".join(chunks)
@router.post("", response_model=FiscalCertificateRead, status_code=status.HTTP_201_CREATED)
async def upload_certificate_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
cnpj: str = Form(..., min_length=14, max_length=14),
file: UploadFile = File(...),
password: str = Form(...),
product: Product = Depends(require_product),
session: AsyncSession = Depends(get_session),
) -> FiscalCertificateRead:
pfx_bytes = await _read_upload_capped(file, _MAX_PFX_UPLOAD_BYTES)
try:
certificate = await service.upload_certificate(
session, product.id, tenant_ref, branch_ref, cnpj, pfx_bytes, password
)
except (certificate_lib.InvalidPfxError, certificate_lib.WrongPasswordError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except service.CertificateCnpjMismatchError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except certificate_lib.CertificateExpiredError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except service.CertificateNotYetValidError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except service.CertificateUploadConflictError as exc:
raise conflict("certificate_upload_conflict", str(exc)) from exc
return FiscalCertificateRead.model_validate(certificate)
@router.get("", response_model=FiscalCertificateRead)
async def read_certificate_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
product: Product = Depends(require_product),
session: AsyncSession = Depends(get_session),
) -> FiscalCertificateRead:
"""Metadados do certificado VIVO -- NUNCA o binário (o .pfx/senha
cifrados nunca saem do banco por esta rota; só `certificates.crypto.
load_private_key_and_cert`, uso interno da emissão, Task 5, os
descriptografa, e só em memória)."""
certificate = await service.get_certificate(session, product.id, tenant_ref, branch_ref)
if certificate is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Certificado não encontrado"
)
return FiscalCertificateRead.model_validate(certificate)
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_certificate_endpoint(
tenant_ref: str = Query(..., max_length=64),
branch_ref: str = Query(..., max_length=64),
product: Product = Depends(require_product),
session: AsyncSession = Depends(get_session),
) -> None:
try:
await service.deactivate_certificate(session, product.id, tenant_ref, branch_ref)
except service.CertificateNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Certificado não encontrado"
) from exc