From 7577ca6dfd092f5cdb2ae8699ecc3fe0afd74fe3 Mon Sep 17 00:00:00 2001 From: ogt Date: Thu, 16 Jul 2026 19:21:00 +0800 Subject: [PATCH] fix(audit): bind jsonb through asyncpg --- apps/api/src/services/audit_sink.py | 2 +- apps/api/src/services/contract_service.py | 2 +- .../test_postgres_jsonb_bind_contract.py | 89 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/api/tests/test_postgres_jsonb_bind_contract.py diff --git a/apps/api/src/services/audit_sink.py b/apps/api/src/services/audit_sink.py index b76e5fb1f..69d5dda01 100644 --- a/apps/api/src/services/audit_sink.py +++ b/apps/api/src/services/audit_sink.py @@ -199,7 +199,7 @@ async def _write_audit_impl( INSERT INTO audit_logs (project_id, action, resource_type, resource_id, details) VALUES - (:project_id, :action, :resource_type, :resource_id, :details::jsonb) + (:project_id, :action, :resource_type, :resource_id, CAST(:details AS JSONB)) """), { "project_id": project_id, diff --git a/apps/api/src/services/contract_service.py b/apps/api/src/services/contract_service.py index 7b2bc235a..5a56bdf74 100644 --- a/apps/api/src/services/contract_service.py +++ b/apps/api/src/services/contract_service.py @@ -430,7 +430,7 @@ async def _write_audit( INSERT INTO audit_logs (project_id, action, resource_type, resource_id, details) VALUES - (:project_id, :action, :resource_type, :resource_id, :details::jsonb) + (:project_id, :action, :resource_type, :resource_id, CAST(:details AS JSONB)) """), { "project_id": project_id, diff --git a/apps/api/tests/test_postgres_jsonb_bind_contract.py b/apps/api/tests/test_postgres_jsonb_bind_contract.py new file mode 100644 index 000000000..e8e5467e6 --- /dev/null +++ b/apps/api/tests/test_postgres_jsonb_bind_contract.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json +import os +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +os.environ.setdefault( + "DATABASE_URL", + "postgresql+asyncpg://test:test@localhost/test", +) + +from src.db import base as db_base # noqa: E402 +from src.services import audit_sink, contract_service # noqa: E402 + + +class _CaptureDb: + def __init__(self) -> None: + self.calls: list[tuple[Any, dict[str, Any]]] = [] + + async def execute( + self, + statement: Any, + params: dict[str, Any], + ) -> None: + self.calls.append((statement, params)) + + +def _assert_jsonb_bind_contract(db: _CaptureDb) -> None: + assert len(db.calls) == 1 + statement, params = db.calls[0] + normalized_sql = " ".join(str(statement).split()) + assert ":details::jsonb" not in normalized_sql + assert "CAST(:details AS JSONB)" in normalized_sql + assert "details" in statement._bindparams + assert "detail" not in statement._bindparams + assert json.loads(params["details"])["result"] == "verified" + + +@pytest.mark.asyncio +async def test_audit_sink_uses_asyncpg_safe_jsonb_bind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + db = _CaptureDb() + + @asynccontextmanager + async def fake_db_context(project_id: str): + assert project_id == "awoooi" + yield db + + monkeypatch.setattr(db_base, "get_db_context", fake_db_context) + + await audit_sink._write_audit_impl( + project_id="awoooi", + action="run.approval.approve", + resource_type="run", + resource_id="run-1", + details={"result": "verified"}, + run_id="run-1", + trace_id="trace-1", + ) + + _assert_jsonb_bind_contract(db) + + +@pytest.mark.asyncio +async def test_contract_service_uses_asyncpg_safe_jsonb_bind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + db = _CaptureDb() + + @asynccontextmanager + async def fake_db_context(project_id: str): + assert project_id == "awoooi" + yield db + + monkeypatch.setattr(db_base, "get_db_context", fake_db_context) + + await contract_service._write_audit( + project_id="awoooi", + action="contract.revision.activated", + resource_type="contract", + resource_id="contract-1", + details={"result": "verified"}, + ) + + _assert_jsonb_bind_contract(db)