"""`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, responses={ 401: {"description": "API key ausente ou inválida"}, 409: {"description": "`detail.code`=`certificate_upload_conflict` -- upload concorrente venceu a corrida"}, 413: {"description": f"Certificado excede o tamanho máximo permitido ({_MAX_PFX_UPLOAD_BYTES} bytes)"}, 422: { "description": ( "PFX inválido/senha incorreta, CNPJ do certificado diverge do declarado, " "certificado vencido ou ainda não vigente" ) }, }, ) 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, responses={ 401: {"description": "API key ausente ou inválida"}, 404: {"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"}, }, ) 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, responses={ 401: {"description": "API key ausente ou inválida"}, 404: {"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"}, }, ) 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