fix(awooop): Phase 2 初批 P0 修正 + Phase 1 Task 1.7 integration tests

## 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>
This commit is contained in:
Your Name
2026-05-04 13:46:19 +08:00
parent 13e51802fe
commit 14bf86a462
5 changed files with 469 additions and 24 deletions

View File

@@ -0,0 +1,422 @@
"""
AwoooP Phase 1 Schema Integration Tests
=========================================
Task 1.7:驗收 awooop_phase1_control_plane_2026-05-04.sql 的核心不變式
測試涵蓋:
1. awooop_projects 基本 CRUDNOT 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.6ADR-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()