Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,16 @@ def get_certificate_pdf_v2(
)


@router.get("/certificates/pubkey")
def certificate_pubkey_v2() -> Dict[str, str]:
"""PÚBLICO: clave pública de firma (Ed25519) para verificar certificados
offline o vía POST /certificates/verify, sin necesidad de cuenta en Mnemo.
Declarado antes que /certificates/{run_id} para que la ruta estática gane."""
if not MNEMO_SIGNING_PUBLIC_KEY:
raise HTTPException(status_code=503, detail="clave pública de firma no configurada")
return {"algorithm": "ed25519", "public_key_pem": MNEMO_SIGNING_PUBLIC_KEY}


@router.get("/certificates/{run_id}", response_model=CertificateResponse)
def get_certificate_v2(
run_id: str,
Expand All @@ -868,13 +878,13 @@ def get_certificate_v2(
@router.post("/certificates/verify", response_model=CertificateVerifyResponse)
def verify_certificate_v2(
body: CertificateVerifyRequest,
user: AuthenticatedUser = Depends(get_current_user),
service: CertificateService = Depends(get_certificate_service),
) -> CertificateVerifyResponse:
try:
valido = service.verify_payload(cert=body.canonical_json, signature=body.signature)
except psycopg.Error as exc:
raise HTTPException(status_code=502, detail="Database error") from exc
# PÚBLICO (sin auth): la verificación es criptografía pura (firma + payload +
# clave pública, nada secreto ni datos de ningún tenant). Un certificado que
# solo puede verificar quien está dentro no es verificable por el auditor/
# regulador/cliente que le da valor — es el núcleo del diferenciador.
valido = service.verify_payload(cert=body.canonical_json, signature=body.signature)
return CertificateVerifyResponse(valido=valido)


Expand Down
6 changes: 4 additions & 2 deletions src/certify/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ def publish(self, *, user_id: str, run_id: str) -> Dict[str, Any]:
if not head_sha:
raise ValueError("el run no tiene commit_sha; no se puede publicar el check run")
verdicts = self.repo.get_triage_for_run(user_id=user_id, run_id=run_id)
if not verdicts:
raise ValueError("run sin veredictos de triaje")
if not verdicts and self.repo.count_failures_for_run(user_id=user_id, run_id=run_id) > 0:
# Run con fallos sin triar → no publicar gate. Un run verde (0 fallos)
# sí publica un check "apto": es el caso central del gate.
raise ValueError("run con fallos sin triar: ejecuta el triaje antes de publicar el gate")
raw_cal = self.repo.get_calibration_metrics(user_id=user_id, org_id=meta["org_id"]) or {}
confidence = compute_confidence({"tenant_accuracy": raw_cal.get("accuracy", 0.0),
"n_corrections": raw_cal.get("total", 0)})
Expand Down
6 changes: 4 additions & 2 deletions src/certify/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ def generate(self, *, user_id: str, run_id: str, created_at: str) -> Dict[str, A
if meta is None:
raise ValueError("run no encontrado o sin acceso")
verdicts = self.repo.get_triage_for_run(user_id=user_id, run_id=run_id)
if not verdicts:
raise ValueError("run sin veredictos de triaje")
if not verdicts and self.repo.count_failures_for_run(user_id=user_id, run_id=run_id) > 0:
# Fallos ingeridos pero sin veredictos = run sin triar → no certificar.
# Sin fallos (run verde) sí se certifica: es el caso central "pasó QA".
raise ValueError("run con fallos sin triar: ejecuta el triaje antes de certificar")
raw_cal = self.repo.get_calibration_metrics(user_id=user_id, org_id=meta["org_id"]) or {}
calibration = {
"tenant_accuracy": raw_cal.get("accuracy", 0.0),
Expand Down
18 changes: 18 additions & 0 deletions src/defects/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,24 @@ def get_triage_for_run(self, *, user_id: str, run_id: str) -> List[Dict[str, Any
for r in cur.fetchall()
]

def count_failures_for_run(self, *, user_id: str, run_id: str) -> int:
"""Nº de fallos ingeridos de un run (0 si no es miembro / no existe).

Distingue un run genuinamente VERDE (0 fallos → certificable como apto) de
uno con fallos aún SIN TRIAR (fallos > 0 pero sin veredictos → no certificar).
"""
with self._connect() as conn:
self._set_claims(conn, user_id)
with conn.cursor() as cur:
cur.execute(
"select count(*) as n from public.failures fl"
" join public.test_runs r on r.id = fl.run_id"
" where fl.run_id = %s and exists (select 1 from public.memberships m"
" where m.org_id = r.org_id and m.user_id = %s)",
(run_id, user_id),
)
return int(cur.fetchone()["n"])

def update_triage_verdict(
self, *, user_id: str, verdict_id: str, category: str, confidence: float,
requires_approval: bool, llm_assisted: bool, status: str,
Expand Down
26 changes: 26 additions & 0 deletions tests/test_api_v2_certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,32 @@ def test_verify_endpoint_real_crypto_true_and_false():
assert bad.status_code == 200 and bad.json()["valido"] is False


def test_verify_endpoint_is_public_no_auth():
# D1: la verificación es cripto pura (firma+payload+clave pública, nada secreto);
# DEBE ser accesible por un tercero sin cuenta en Mnemo, o el certificado
# "verificable" no lo es para el auditor/regulador que le da valor.
svc = MagicMock()
svc.verify_payload.return_value = True
resp = _client(service=svc, with_user=False).post(
"/v2/certificates/verify", json={"canonical_json": {"verdict": "apto"}, "signature": "s"})
assert resp.status_code == 200 and resp.json()["valido"] is True


def test_pubkey_endpoint_public_returns_key(monkeypatch):
monkeypatch.setattr(api_v2, "MNEMO_SIGNING_PUBLIC_KEY", "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----")
resp = _client(service=MagicMock(), with_user=False).get("/v2/certificates/pubkey")
assert resp.status_code == 200
body = resp.json()
assert body["algorithm"] == "ed25519"
assert "BEGIN PUBLIC KEY" in body["public_key_pem"]


def test_pubkey_endpoint_503_when_unset(monkeypatch):
monkeypatch.setattr(api_v2, "MNEMO_SIGNING_PUBLIC_KEY", "")
resp = _client(service=MagicMock(), with_user=False).get("/v2/certificates/pubkey")
assert resp.status_code == 503


def test_requires_auth():
assert _client(service=MagicMock(), with_user=False).post("/v2/certificates/run/r1").status_code == 401

Expand Down
17 changes: 14 additions & 3 deletions tests/test_certify_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from src.certify.gate import GateService


def _service(*, meta, verdicts, codehost=None, calibration=None):
def _service(*, meta, verdicts, codehost=None, calibration=None, n_failures=0):
repo = MagicMock()
repo.get_triage_for_run.return_value = verdicts
repo.count_failures_for_run.return_value = n_failures
repo.get_calibration_metrics.return_value = calibration # None → or {} → confidence "low"
cert_repo = MagicMock()
cert_repo.get_run_meta.return_value = meta
Expand Down Expand Up @@ -54,12 +55,22 @@ def test_publish_raises_without_commit_sha():
svc.publish(user_id="u", run_id="r")


def test_publish_raises_without_verdicts():
svc, _, _ = _service(meta=_META, verdicts=[])
def test_publish_raises_when_failures_untriaged():
# Fallos ingeridos pero sin veredictos = run sin triar → no publicar gate.
svc, _, _ = _service(meta=_META, verdicts=[], n_failures=3)
with pytest.raises(ValueError):
svc.publish(user_id="u", run_id="r")


def test_publish_green_run_is_success():
# D2: run VERDE (0 fallos, 0 veredictos) → gate "apto"/success (caso central).
cal = {"accuracy": 0.85, "total": 150, "por_categoria": {}}
svc, codehost, _ = _service(meta=_META, verdicts=[], n_failures=0, calibration=cal)
out = svc.publish(user_id="u", run_id="r")
assert out["verdict"] == "apto" and out["conclusion"] == "success"
assert codehost.publish_check_run.called


def test_publish_raises_when_run_not_found():
svc, _, _ = _service(meta=None, verdicts=[_v("flaky")])
with pytest.raises(ValueError):
Expand Down
33 changes: 33 additions & 0 deletions tests/test_certify_service_aieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,36 @@ def test_generate_degrades_to_none_when_judge_raises(monkeypatch):
assert result["canonical_json"]["self_eval"]["ai_eval"] is None, (
"ai_eval should be None when compute_ai_eval raises"
)


# ---------------------------------------------------------------------------
# D2 — certificar un release VERDE (0 fallos, 0 veredictos) vs. uno SIN TRIAR
# ---------------------------------------------------------------------------

def _make_service_greenish(priv, pub, *, verdicts, n_failures, calibration=None):
repo = MagicMock()
repo.get_triage_for_run.return_value = verdicts
repo.count_failures_for_run.return_value = n_failures
repo.get_calibration_metrics.return_value = calibration or {}
cert_repo = MagicMock()
cert_repo.get_run_meta.return_value = _META
cert_repo.save_certificate.return_value = None
return CertificateService(repo=repo, cert_repo=cert_repo, private_key=priv, public_key=pub,
mnemo_version="1.0", model_version="test-model")


def test_generate_green_run_certifies_apto():
priv, pub = _keys()
cal = {"accuracy": 0.85, "total": 150, "por_categoria": {}}
svc = _make_service_greenish(priv, pub, verdicts=[], n_failures=0, calibration=cal)
result = svc.generate(user_id="u1", run_id="r1", created_at=_CREATED_AT)
assert result["verdict"] == "apto"
assert result["risk_score"] == 0
assert result["canonical_json"]["evidence"] == []


def test_generate_untriaged_run_raises():
priv, pub = _keys()
svc = _make_service_greenish(priv, pub, verdicts=[], n_failures=5)
with pytest.raises(ValueError):
svc.generate(user_id="u1", run_id="r1", created_at=_CREATED_AT)
Loading