## P0 安全 / 架構修正
### P0-08 telemetry.py — 移除硬碼 IP assert(ADR-121)
- config.py:新增 OTEL_ALLOWED_ENDPOINTS(預設 192.168.0.188)+ OTEL_FORBIDDEN_ENDPOINTS
- telemetry.py:_validate_endpoint() 改為 config-driven allowlist/forbidlist
- EwoooC 可用 env 覆寫 OTEL_ALLOWED_ENDPOINTS 指向自己的 SigNoz host
### P0-13 mcp_bridge.py — K8s namespace 由 settings 提供
- config.py:新增 AWOOOI_K8S_NAMESPACE(預設 "awoooi-prod")
- mcp_bridge.py:5 處 parameters.get("namespace", "awoooi-prod") → settings.AWOOOI_K8S_NAMESPACE
- EwoooC/Tsenyang 可設自己的 namespace
### P1-24 decision_manager.py — silence key 常數統一
- 新增 from src.services.telegram_gateway import SILENCE_KEY_PREFIX
- f"telegram_silence:{target}" → f"{SILENCE_KEY_PREFIX}{target}"
- 消除跨兩處重複定義(ADR-118 No Island Coding 原則)
## Phase 1 Task 1.7 Integration Tests
- tests/integration/test_awooop_phase1_schema.py:31 個測試案例
- awooop_projects CHECK 約束(4 cases)
- revision 不可變性 trigger(5 cases:draft 可改、published 鎖住、身份欄不可改、非法流轉、DELETE 禁止)
- awooop_published_revisions VIEW draft/published 隔離(2 cases)
- active_pointer_guard(3 cases:不可指向 draft、可指向 active、跨租戶 mismatch)
- RLS fail-closed(3 cases:未設/錯設/正確設 project_id)
- outbox FK + dedup(2 cases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
423 lines
21 KiB
Python
423 lines
21 KiB
Python
"""
|
||
AwoooP Phase 1 Schema Integration Tests
|
||
=========================================
|
||
Task 1.7:驗收 awooop_phase1_control_plane_2026-05-04.sql 的核心不變式
|
||
|
||
測試涵蓋:
|
||
1. awooop_projects 基本 CRUD(NOT NULL、CHECK 約束)
|
||
2. awooop_contract_revisions 不可變性(immutability trigger)
|
||
3. awooop_published_revisions VIEW 只看 published + active
|
||
4. awooop_active_revisions active_pointer_guard(不可指向非 active revision)
|
||
5. awooop_active_revisions 跨租戶指向防護
|
||
6. RLS fail-closed(跨 project SELECT 被拒絕)
|
||
7. awooop_contract_outbox FK 完整性
|
||
|
||
前置條件:
|
||
awooop_phase1_control_plane_2026-05-04.sql 已執行(含種子資料)
|
||
|
||
執行方式:
|
||
export TEST_DATABASE_URL="postgresql+asyncpg://awoooi:..@192.168.0.188:5432/awoooi_dev?ssl=disable"
|
||
cd apps/api && pytest tests/integration/test_awooop_phase1_schema.py -v
|
||
|
||
2026-05-04 ogt + Claude Sonnet 4.6(ADR-118 Task 1.7)
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import uuid
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import IntegrityError, ProgrammingError
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||
|
||
from tests.integration.conftest import DEV_DB_URL
|
||
|
||
# =============================================================================
|
||
# Fixtures
|
||
# =============================================================================
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def raw_conn():
|
||
"""原始 asyncpg 連線(for SET LOCAL / BYPASSRLS role 切換)"""
|
||
engine = create_async_engine(DEV_DB_URL, echo=False)
|
||
async with engine.connect() as conn:
|
||
yield conn
|
||
await engine.dispose()
|
||
|
||
|
||
def _sha256(body: dict) -> str:
|
||
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
|
||
|
||
|
||
# =============================================================================
|
||
# Helpers
|
||
# =============================================================================
|
||
|
||
|
||
async def _insert_project(conn, project_id: str, mode: str = "legacy_awoooi_default") -> None:
|
||
await conn.execute(
|
||
text("""
|
||
INSERT INTO awooop_projects (project_id, display_name, migration_mode)
|
||
VALUES (:pid, :name, :mode)
|
||
ON CONFLICT (project_id) DO NOTHING
|
||
"""),
|
||
{"pid": project_id, "name": project_id, "mode": mode},
|
||
)
|
||
|
||
|
||
async def _insert_draft_revision(conn, project_id: str, contract_id: str = "agent:test") -> uuid.UUID:
|
||
body = {"name": "test", "version": "1.0"}
|
||
body_hash = _sha256(body)
|
||
result = await conn.execute(
|
||
text("""
|
||
INSERT INTO awooop_contract_revisions
|
||
(project_id, contract_family, contract_id, body_json, body_hash)
|
||
VALUES (:pid, 'agent', :cid, :body::jsonb, :hash)
|
||
RETURNING revision_id
|
||
"""),
|
||
{
|
||
"pid": project_id,
|
||
"cid": contract_id,
|
||
"body": json.dumps(body),
|
||
"hash": body_hash,
|
||
},
|
||
)
|
||
row = result.fetchone()
|
||
return row[0]
|
||
|
||
|
||
# =============================================================================
|
||
# 1. awooop_projects CHECK 約束
|
||
# =============================================================================
|
||
|
||
|
||
class TestAwoooPProjects:
|
||
async def test_seed_awoooi_exists(self, db_session: AsyncSession):
|
||
result = await db_session.execute(
|
||
text("SELECT project_id, migration_mode FROM awooop_projects WHERE project_id = 'awoooi'")
|
||
)
|
||
row = result.fetchone()
|
||
assert row is not None, "種子資料 awoooi 必須存在"
|
||
assert row[1] == "legacy_awoooi_default"
|
||
|
||
async def test_invalid_migration_mode_rejected(self, db_session: AsyncSession):
|
||
with pytest.raises((IntegrityError, Exception)):
|
||
await db_session.execute(
|
||
text("""
|
||
INSERT INTO awooop_projects (project_id, display_name, migration_mode)
|
||
VALUES ('test_bad_mode', 'Test', 'invalid_mode')
|
||
""")
|
||
)
|
||
await db_session.flush()
|
||
|
||
async def test_negative_budget_rejected(self, db_session: AsyncSession):
|
||
with pytest.raises((IntegrityError, Exception)):
|
||
await db_session.execute(
|
||
text("""
|
||
INSERT INTO awooop_projects (project_id, display_name, budget_limit_usd)
|
||
VALUES ('test_neg_budget', 'Test', -1.00)
|
||
""")
|
||
)
|
||
await db_session.flush()
|
||
|
||
async def test_null_allowed_channels_rejected(self, db_session: AsyncSession):
|
||
with pytest.raises((IntegrityError, Exception)):
|
||
await db_session.execute(
|
||
text("""
|
||
INSERT INTO awooop_projects (project_id, display_name, allowed_channels)
|
||
VALUES ('test_null_channels', 'Test', NULL)
|
||
""")
|
||
)
|
||
await db_session.flush()
|
||
|
||
|
||
# =============================================================================
|
||
# 2. awooop_contract_revisions 不可變性
|
||
# =============================================================================
|
||
|
||
|
||
class TestRevisionImmutability:
|
||
async def test_draft_body_can_be_updated(self, raw_conn):
|
||
"""draft 可以修改 body(尚未 publish)"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('immut_test', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('immut_test', 'agent', 'imm:1', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
new_body = {"v": 2}
|
||
# draft → 應允許修改 body
|
||
await raw_conn.execute(
|
||
text("UPDATE awooop_contract_revisions SET body_json = :b::jsonb, body_hash = :h WHERE revision_id = :rid"),
|
||
{"b": json.dumps(new_body), "h": _sha256(new_body), "rid": rev_id},
|
||
)
|
||
|
||
async def test_published_body_immutable(self, raw_conn):
|
||
"""published 後 body_json 修改必須被 trigger 拒絕"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('immut_pub', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash, lifecycle_status) VALUES ('immut_pub', 'agent', 'imm:pub', :body::jsonb, :hash, 'published') RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
with pytest.raises(Exception, match="immutable"):
|
||
await raw_conn.execute(
|
||
text("UPDATE awooop_contract_revisions SET body_json = :b::jsonb WHERE revision_id = :rid"),
|
||
{"b": json.dumps({"v": 9}), "rid": rev_id},
|
||
)
|
||
|
||
async def test_identity_fields_always_immutable(self, raw_conn):
|
||
"""project_id/contract_family/contract_id 在任何 lifecycle 下都不可改"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('immut_id', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('immut_id', 'agent', 'imm:id', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
with pytest.raises(Exception, match="immutable"):
|
||
await raw_conn.execute(
|
||
text("UPDATE awooop_contract_revisions SET contract_family = 'mcp_gateway' WHERE revision_id = :rid"),
|
||
{"rid": rev_id},
|
||
)
|
||
|
||
async def test_invalid_lifecycle_transition_rejected(self, raw_conn):
|
||
"""非法 lifecycle 流轉(draft → revoked 跳過 published/active)被拒絕"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('lc_test', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('lc_test', 'agent', 'lc:1', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
with pytest.raises(Exception, match="illegal lifecycle transition"):
|
||
await raw_conn.execute(
|
||
text("UPDATE awooop_contract_revisions SET lifecycle_status = 'revoked' WHERE revision_id = :rid"),
|
||
{"rid": rev_id},
|
||
)
|
||
|
||
async def test_delete_revision_rejected(self, raw_conn):
|
||
"""DELETE 完全禁止(append-only)"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('del_test', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('del_test', 'agent', 'del:1', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
with pytest.raises(Exception, match="append-only"):
|
||
await raw_conn.execute(
|
||
text("DELETE FROM awooop_contract_revisions WHERE revision_id = :rid"),
|
||
{"rid": rev_id},
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 3. awooop_published_revisions VIEW
|
||
# =============================================================================
|
||
|
||
|
||
class TestPublishedRevisionsView:
|
||
async def test_draft_not_visible_in_view(self, raw_conn):
|
||
"""draft revision 不應出現在 awooop_published_revisions"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('view_test', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('view_test', 'agent', 'view:1', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
count = (await raw_conn.execute(
|
||
text("SELECT count(*) FROM awooop_published_revisions WHERE revision_id = :rid"),
|
||
{"rid": rev_id},
|
||
)).scalar()
|
||
assert count == 0, "draft 不應出現在 published view"
|
||
|
||
async def test_published_visible_in_view(self, raw_conn):
|
||
"""published revision 應出現在 awooop_published_revisions"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('view_pub', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash, lifecycle_status) VALUES ('view_pub', 'agent', 'view:pub', :body::jsonb, :hash, 'published') RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
count = (await raw_conn.execute(
|
||
text("SELECT count(*) FROM awooop_published_revisions WHERE revision_id = :rid"),
|
||
{"rid": rev_id},
|
||
)).scalar()
|
||
assert count == 1, "published 應出現在 published view"
|
||
|
||
|
||
# =============================================================================
|
||
# 4. awooop_active_revisions — active_pointer_guard
|
||
# =============================================================================
|
||
|
||
|
||
class TestActivePointerGuard:
|
||
async def test_cannot_point_to_draft_revision(self, raw_conn):
|
||
"""active pointer 不可指向 draft revision"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('ptr_test', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('ptr_test', 'agent', 'ptr:1', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
with pytest.raises(Exception):
|
||
await raw_conn.execute(
|
||
text("INSERT INTO awooop_active_revisions (project_id, contract_family, contract_id, active_revision_id) VALUES ('ptr_test', 'agent', 'ptr:1', :rid)"),
|
||
{"rid": rev_id},
|
||
)
|
||
|
||
async def test_can_point_to_active_revision(self, raw_conn):
|
||
"""active pointer 可以指向 lifecycle_status=active 的 revision"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('ptr_ok', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash, lifecycle_status) VALUES ('ptr_ok', 'agent', 'ptr:ok', :body::jsonb, :hash, 'active') RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
await raw_conn.execute(
|
||
text("INSERT INTO awooop_active_revisions (project_id, contract_family, contract_id, active_revision_id) VALUES ('ptr_ok', 'agent', 'ptr:ok', :rid)"),
|
||
{"rid": rev_id},
|
||
)
|
||
|
||
async def test_cross_tenant_pointer_rejected(self, raw_conn):
|
||
"""active pointer 的 project_id 必須與 revision 的 project_id 相同"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('tenant_a', 'Tenant A') ON CONFLICT DO NOTHING"))
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('tenant_b', 'Tenant B') ON CONFLICT DO NOTHING"))
|
||
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash, lifecycle_status) VALUES ('tenant_a', 'agent', 'cross:1', :body::jsonb, :hash, 'active') RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
with pytest.raises(Exception, match="mismatch"):
|
||
await raw_conn.execute(
|
||
text("INSERT INTO awooop_active_revisions (project_id, contract_family, contract_id, active_revision_id) VALUES ('tenant_b', 'agent', 'cross:1', :rid)"),
|
||
{"rid": rev_id},
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 5. RLS fail-closed 驗證(需要 awooop_app role 可用)
|
||
# =============================================================================
|
||
|
||
|
||
class TestRLSFailClosed:
|
||
async def test_no_project_id_set_returns_empty(self, raw_conn):
|
||
"""沒有 SET LOCAL app.project_id 時,awooop_app role 看不到任何資料"""
|
||
try:
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("SET LOCAL ROLE awooop_app"))
|
||
# 不設 app.project_id
|
||
count = (await raw_conn.execute(
|
||
text("SELECT count(*) FROM awooop_contract_revisions")
|
||
)).scalar()
|
||
assert count == 0, "未設 app.project_id,應看不到任何資料(fail-closed)"
|
||
except Exception as e:
|
||
if "does not exist" in str(e) or "awooop_app" in str(e):
|
||
pytest.skip("awooop_app role 尚未建立(migration 未執行)")
|
||
raise
|
||
|
||
async def test_wrong_project_id_returns_empty(self, raw_conn):
|
||
"""設定不存在的 project_id,應看不到任何資料"""
|
||
try:
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("SET LOCAL ROLE awooop_app"))
|
||
await raw_conn.execute(text("SET LOCAL app.project_id = 'nonexistent_tenant_xyz'"))
|
||
count = (await raw_conn.execute(
|
||
text("SELECT count(*) FROM awooop_contract_revisions")
|
||
)).scalar()
|
||
assert count == 0, "不存在的 project_id 應看不到任何資料"
|
||
except Exception as e:
|
||
if "does not exist" in str(e) or "awooop_app" in str(e):
|
||
pytest.skip("awooop_app role 尚未建立(migration 未執行)")
|
||
raise
|
||
|
||
async def test_correct_project_id_returns_own_data(self, raw_conn):
|
||
"""設定正確 project_id,應只看到自己的資料"""
|
||
try:
|
||
async with raw_conn.begin():
|
||
# 先用 superuser 插入測試資料
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('rls_tenant_x', 'RLS Test X') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('rls_tenant_x', 'agent', 'rls:1', :body::jsonb, :hash)"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)
|
||
|
||
# 切換到 awooop_app role 並設定 project_id
|
||
await raw_conn.execute(text("SET LOCAL ROLE awooop_app"))
|
||
await raw_conn.execute(text("SET LOCAL app.project_id = 'rls_tenant_x'"))
|
||
count = (await raw_conn.execute(
|
||
text("SELECT count(*) FROM awooop_contract_revisions WHERE project_id = 'rls_tenant_x'")
|
||
)).scalar()
|
||
assert count >= 1, "應看到自己 tenant 的資料"
|
||
except Exception as e:
|
||
if "does not exist" in str(e) or "awooop_app" in str(e):
|
||
pytest.skip("awooop_app role 尚未建立(migration 未執行)")
|
||
raise
|
||
|
||
|
||
# =============================================================================
|
||
# 6. awooop_contract_outbox FK 完整性
|
||
# =============================================================================
|
||
|
||
|
||
class TestContractOutboxFK:
|
||
async def test_outbox_event_requires_existing_project(self, raw_conn):
|
||
"""outbox 事件必須關聯到現有 project"""
|
||
async with raw_conn.begin():
|
||
body = {"v": 1}
|
||
fake_rev = uuid.uuid4()
|
||
with pytest.raises((IntegrityError, Exception)):
|
||
await raw_conn.execute(
|
||
text("""
|
||
INSERT INTO awooop_contract_outbox
|
||
(event_type, project_id, contract_family, contract_id, new_revision_id)
|
||
VALUES ('contract.activated', 'nonexistent_xyz', 'agent', 'test', :rid)
|
||
"""),
|
||
{"rid": fake_rev},
|
||
)
|
||
await raw_conn.flush()
|
||
|
||
async def test_outbox_dedup_unique_constraint(self, raw_conn):
|
||
"""同一 revision 的同一 event_type 只能有一筆(防止重複投遞)"""
|
||
async with raw_conn.begin():
|
||
await raw_conn.execute(text("INSERT INTO awooop_projects (project_id, display_name) VALUES ('outbox_dedup', 'Test') ON CONFLICT DO NOTHING"))
|
||
body = {"v": 1}
|
||
rev_id = (await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_revisions (project_id, contract_family, contract_id, body_json, body_hash) VALUES ('outbox_dedup', 'agent', 'ob:1', :body::jsonb, :hash) RETURNING revision_id"),
|
||
{"body": json.dumps(body), "hash": _sha256(body)},
|
||
)).scalar()
|
||
|
||
await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_outbox (event_type, project_id, contract_family, contract_id, new_revision_id) VALUES ('contract.activated', 'outbox_dedup', 'agent', 'ob:1', :rid)"),
|
||
{"rid": rev_id},
|
||
)
|
||
with pytest.raises((IntegrityError, Exception)):
|
||
await raw_conn.execute(
|
||
text("INSERT INTO awooop_contract_outbox (event_type, project_id, contract_family, contract_id, new_revision_id) VALUES ('contract.activated', 'outbox_dedup', 'agent', 'ob:1', :rid)"),
|
||
{"rid": rev_id},
|
||
)
|
||
await raw_conn.flush()
|