IMPORTANT (F2 review): certificate is per-tenant, matching series (already tenant-scoped) and the GET/DELETE anti-oracle boundary. Emission's _get_live_certificate and the upload-replace pre-check (certificates. service, renamed _get_live_certificate_by_branch -> _get_live_certificate_by_tenant_branch) both omitted tenant_ref -- two tenants of one product reusing branch_ref="matriz" collapsed onto the same slot: B's upload soft-deleted A's still-live certificate, and A's emission went on to sign with B's certificate. Migration 8f1a2c9d4b6e replaces the partial-unique index ix_fiscal_certificates_product_branch_live with ix_fiscal_certificates_product_tenant_branch_live on (product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL, with a working downgrade. Upload's two-layer defense (pre-check + IntegrityError -> CertificateUploadConflictError) still holds against the new index. Tests: - tests/emission/test_emissao.py:: test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificados_isolados -- two tenants upload for the same product/branch_ref, both stay live; emission for each signs with its OWN certificate (observable via FIX 1's CNPJ check: without FIX 2, tenant A's emission would 409 emitente_certificate_cnpj_mismatch because the "live" cert would actually be B's). - tests/migrations/test_fiscal_documents_schema.py:: test_two_tenants_can_both_hold_a_live_certificate_for_the_same_branch_ref_on_real_migration -- real alembic upgrade head, raw INSERTs proving both tenants' certs land live. - tests/migrations/test_fiscal_documents_schema.py:: test_two_live_certificates_for_same_product_tenant_branch_violate_unique_index_on_real_migration (renamed from ..._product_branch_...) -- same (product, tenant, branch) still rejects a second live certificate on the real migration. - tests/certificates/test_certificates.py:: test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409 updated for the renamed/re-scoped precheck function (still same-tenant race, still 1 winner + 1 CertificateUploadConflictError). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
437 lines
18 KiB
Python
437 lines
18 KiB
Python
"""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}"
|
|
product = 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"]
|
|
|
|
# `product_id` scoped -- `tenant_ref="t1"`/`branch_ref="b1"` are literals
|
|
# reused by MANY tests in this file/suite (the `db_session` fixture only
|
|
# rolls back at teardown, it does not truncate what earlier tests already
|
|
# committed), so an unscoped query here would count every OTHER test's
|
|
# certificates for the same branch_ref too.
|
|
result = await db_session.execute(
|
|
select(FiscalCertificate).where(
|
|
FiscalCertificate.product_id == product.id, 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, monkeypatch
|
|
):
|
|
"""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.
|
|
|
|
Sem sincronização explícita, as duas corrotinas rodam no MESMO event
|
|
loop e podem interleavear de um jeito que NÃO exercita a corrida real:
|
|
se a primeira `upload_certificate` COMMITA inteiro antes de a segunda
|
|
fazer o pre-check `_get_live_certificate_by_tenant_branch`, a segunda
|
|
enxerga a linha viva da primeira e faz um REPLACE LEGÍTIMO (soft-delete
|
|
+ insert) -- 2 sucessos, 1 linha viva, comportamento CORRETO do
|
|
serviço, mas que quebraria a asserção abaixo (que exige exatamente 1
|
|
sucesso + 1 conflito). Mesma técnica de sincronização determinística de
|
|
`auto/backend/tests/modules/financeiro/test_pay_account_payable.py::
|
|
test_pay_concurrent_with_cancel_via_http_lock_serializes_the_race`:
|
|
monkeypatch no ponto de await entre o pre-check e o commit, com um
|
|
`asyncio.Event`, para FORÇAR a janela vulnerável -- as duas chamadas
|
|
fazem o pre-check (ambas leem `None`) ANTES de qualquer uma commitar,
|
|
e só depois disso o resultado passa a depender só do índice parcial
|
|
único do banco (determinístico: 1 vencedor, 1 `IntegrityError`
|
|
traduzido em `CertificateUploadConflictError`)."""
|
|
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")
|
|
|
|
original_precheck = certificate_service._get_live_certificate_by_tenant_branch
|
|
precheck_done = asyncio.Event()
|
|
first_precheck_claimed = False
|
|
|
|
async def _precheck_forcing_both_before_any_commit(session, product_id, tenant_ref, branch_ref):
|
|
nonlocal first_precheck_claimed
|
|
if not first_precheck_claimed:
|
|
first_precheck_claimed = True
|
|
result = await original_precheck(session, product_id, tenant_ref, branch_ref)
|
|
precheck_done.set()
|
|
# Segura ESTA chamada (ainda antes do commit em upload_certificate)
|
|
# até depois que a outra também tenha feito seu pre-check --
|
|
# garante que as DUAS leem "nenhum certificado vivo" antes de
|
|
# qualquer uma escrever.
|
|
await asyncio.sleep(0.3)
|
|
return result
|
|
await precheck_done.wait()
|
|
return await original_precheck(session, product_id, tenant_ref, branch_ref)
|
|
|
|
monkeypatch.setattr(
|
|
certificate_service,
|
|
"_get_live_certificate_by_tenant_branch",
|
|
_precheck_forcing_both_before_any_commit,
|
|
)
|
|
|
|
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"
|
|
)
|