""" 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()