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:
@@ -0,0 +1,387 @@
|
||||
"""Task 4: `POST/GET/DELETE /v1/certificados` -- ported from the auto's
|
||||
`tests/modules/fiscal/test_certificate.py`, porte table applied (JWT bearer
|
||||
+ `branch_id` path segment -> `X-Api-Key` + `tenant_ref`/`branch_ref` query
|
||||
params; anti-oracle now by `(product_id, tenant_ref)` instead of
|
||||
`organization_id`).
|
||||
|
||||
Fixture de PFX: gerado em memória via `cryptography` (chave RSA 2048 +
|
||||
certificado self-signed com o CNPJ no subject, formato ICP-Brasil e-CNPJ
|
||||
real -- `CN=RAZAO SOCIAL:CNPJ` + atributo `SERIALNUMBER`) -- NUNCA um
|
||||
certificado real no repo, mesma convenção do auto."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
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 sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from fiscal_svc.certificates import crypto as certificate_lib
|
||||
from fiscal_svc.certificates import service as certificate_service
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.documents.models import FiscalCertificate
|
||||
from fiscal_svc.main import app
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
|
||||
@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"))
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
yield
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _build_test_pfx(
|
||||
*,
|
||||
cnpj: str = "14200166000187",
|
||||
password: str = "correct-horse-battery",
|
||||
not_valid_before: datetime | None = None,
|
||||
not_valid_after: datetime | None = None,
|
||||
cn: str | None = None,
|
||||
) -> bytes:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, cn or f"EMPRESA TESTE LTDA:{cnpj}"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj),
|
||||
]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
nvb = not_valid_before if not_valid_before is not None else now - timedelta(days=1)
|
||||
nva = not_valid_after if not_valid_after is not None else now + timedelta(days=365)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(nvb)
|
||||
.not_valid_after(nva)
|
||||
.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")),
|
||||
)
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
async def _upload(client, api_key, tenant_ref, branch_ref, cnpj, pfx_bytes, password):
|
||||
return await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": tenant_ref, "branch_ref": branch_ref},
|
||||
files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")},
|
||||
data={"password": password, "cnpj": cnpj},
|
||||
headers=_headers(api_key),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_valid_pfx_returns_201_with_correct_metadata_and_encrypted_binary(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "tenant-1", "branch-1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["branch_ref"] == "branch-1"
|
||||
assert body["tenant_ref"] == "tenant-1"
|
||||
assert body["cnpj_certificado"] == "14200166000187"
|
||||
assert "EMPRESA TESTE LTDA" in body["subject_cn"]
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalCertificate).where(FiscalCertificate.id == uuid.UUID(body["id"]))
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.pfx_encrypted != pfx_bytes
|
||||
assert pfx_bytes not in row.pfx_encrypted
|
||||
assert certificate_lib.decrypt_bytes(row.pfx_encrypted) == pfx_bytes
|
||||
assert certificate_lib.decrypt_bytes(row.password_encrypted) == b"senha123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_wrong_password_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="correct-pw")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "wrong-pw")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_pfx_file_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", b"isso nao e um pfx", "qualquer")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_expired_certificate_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
now = datetime.now(timezone.utc)
|
||||
pfx_bytes = _build_test_pfx(
|
||||
cnpj="14200166000187", password="senha123",
|
||||
not_valid_before=now - timedelta(days=400), not_valid_after=now - timedelta(days=10),
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_not_valid_before_in_future_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
now = datetime.now(timezone.utc)
|
||||
pfx_bytes = _build_test_pfx(
|
||||
cnpj="14200166000187", password="senha123",
|
||||
not_valid_before=now + timedelta(days=5), not_valid_after=now + timedelta(days=400),
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_cnpj_mismatch_between_payload_and_certificate_is_422_naming_both(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="99887766000155", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
assert "14200166000187" in response.text
|
||||
assert "99887766000155" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_certificate_returns_metadata_without_binary(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["cnpj_certificado"] == "14200166000187"
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_product_branch_is_404_not_leaked(db_session):
|
||||
key_a = f"k-{uuid.uuid4().hex}"
|
||||
key_b = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key_a)
|
||||
await create_product(db_session, name="crm", api_key=key_b)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key_a, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key_b)
|
||||
)
|
||||
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_ref_same_product_is_404(db_session):
|
||||
"""Anti-oracle boundary is `(product_id, tenant_ref)` (design spec
|
||||
decision #4) -- even under the SAME product, a wrong `tenant_ref` for a
|
||||
real `branch_ref` must 404, not leak the certificate."""
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "tenant-a", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "tenant-b", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_upload_replaces_the_first_soft_deleting_it(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
first_pfx = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="PRIMEIRO:14200166000187")
|
||||
second_pfx = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="SEGUNDO:14200166000187")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first_response = await _upload(client, key, "t1", "b1", "14200166000187", first_pfx, "senha123")
|
||||
second_response = await _upload(client, key, "t1", "b1", "14200166000187", second_pfx, "senha456")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert first_response.status_code == 201, first_response.text
|
||||
assert second_response.status_code == 201, second_response.text
|
||||
assert get_response.json()["id"] == second_response.json()["id"]
|
||||
assert "SEGUNDO" in get_response.json()["subject_cn"]
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalCertificate).where(FiscalCertificate.branch_ref == "b1")
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 2
|
||||
live = [r for r in rows if r.deleted_at is None]
|
||||
dead = [r for r in rows if r.deleted_at is not None]
|
||||
assert len(live) == 1
|
||||
assert len(dead) == 1
|
||||
assert live[0].id == uuid.UUID(second_response.json()["id"])
|
||||
assert dead[0].id == uuid.UUID(first_response.json()["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_soft_deletes_and_get_then_404s(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
delete_response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert delete_response.status_code == 204, delete_response.text
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_without_certificate_is_404(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_api_key_is_401(db_session):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"})
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409(
|
||||
test_engine, db_session
|
||||
):
|
||||
"""Mesma prova do auto (`test_concurrent_uploads_for_same_branch_only_
|
||||
one_wins_the_other_gets_409`): duas `AsyncSession` distintas contra o
|
||||
MESMO `test_engine`, disparadas via `asyncio.gather` -- concorrência
|
||||
REAL, não simulada."""
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_a = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="A:14200166000187")
|
||||
pfx_b = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="B:14200166000187")
|
||||
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
session_a = session_maker()
|
||||
session_b = session_maker()
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
certificate_service.upload_certificate(
|
||||
session_a, product.id, "t1", "b1", "14200166000187", pfx_a, "senha123"
|
||||
),
|
||||
certificate_service.upload_certificate(
|
||||
session_b, product.id, "t1", "b1", "14200166000187", pfx_b, "senha456"
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
await session_a.close()
|
||||
await session_b.close()
|
||||
|
||||
successes = [r for r in results if not isinstance(r, BaseException)]
|
||||
errors = [r for r in results if isinstance(r, BaseException)]
|
||||
|
||||
assert len(successes) == 1, f"expected exactly 1 winner, got {len(successes)}: {results!r}"
|
||||
assert len(errors) == 1, f"expected exactly 1 conflict error, got {len(errors)}: {results!r}"
|
||||
assert isinstance(errors[0], certificate_service.CertificateUploadConflictError), errors[0]
|
||||
|
||||
live_result = await db_session.execute(
|
||||
select(FiscalCertificate).where(
|
||||
FiscalCertificate.product_id == product.id,
|
||||
FiscalCertificate.branch_ref == "b1",
|
||||
FiscalCertificate.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
live_rows = live_result.scalars().all()
|
||||
assert len(live_rows) == 1, (
|
||||
f"expected exactly 1 live certificate after the race, found {len(live_rows)} -- "
|
||||
"the DB-level partial unique index should have blocked the second insert"
|
||||
)
|
||||
Reference in New Issue
Block a user