diff --git a/backend/tests/test_admin_categories_global.py b/backend/tests/test_admin_categories_global.py index 1ae6212a..7265771c 100644 --- a/backend/tests/test_admin_categories_global.py +++ b/backend/tests/test_admin_categories_global.py @@ -29,7 +29,7 @@ class TestAdminGlobalCategories: data = response.json() assert data["name"] == "Test Category" assert data["slug"] == "test-category" - assert data["account_id"] is None + assert data["account_id"] == "00000000-0000-0000-0000-000000000001" # PLATFORM_ACCOUNT_ID @pytest.mark.asyncio async def test_update_global_category( diff --git a/backend/tests/test_permissions_account.py b/backend/tests/test_permissions_account.py index a18067a9..b25e314b 100644 --- a/backend/tests/test_permissions_account.py +++ b/backend/tests/test_permissions_account.py @@ -200,6 +200,7 @@ class TestAccountPermissions: }) outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"} - # Outsider should NOT see the private tree + # Outsider should NOT see the private tree. + # With RLS, the tree is invisible to other tenants — 404 not 403. response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers) - assert response.status_code == 403 + assert response.status_code == 404 diff --git a/backend/tests/test_rls_isolation.py b/backend/tests/test_rls_isolation.py index 2a85cc5f..4c69e3df 100644 --- a/backend/tests/test_rls_isolation.py +++ b/backend/tests/test_rls_isolation.py @@ -24,6 +24,12 @@ from pathlib import Path import asyncpg import pytest +# All tests in this module use module-scoped async fixtures (admin_conn, +# seed_rls_test_data) which run on the module event loop. Without this marker, +# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing +# "Future attached to a different loop" errors on the asyncpg connections. +pytestmark = pytest.mark.asyncio(loop_scope="module") + _DB_HOST = os.getenv("TEST_DB_HOST", "localhost") _DB_PORT = int(os.getenv("TEST_DB_PORT", "5432")) _DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py @@ -191,7 +197,6 @@ async def conn_no_context(): # trees # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_trees_account_a_cannot_see_account_b_rows(conn_a): rows = await conn_a.fetch( f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'" @@ -199,7 +204,6 @@ async def test_trees_account_a_cannot_see_account_b_rows(conn_a): assert len(rows) == 0, "Account A should not see Account B trees" -@pytest.mark.asyncio async def test_trees_account_a_can_see_own_rows(conn_a): rows = await conn_a.fetch( f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'" @@ -207,7 +211,6 @@ async def test_trees_account_a_can_see_own_rows(conn_a): assert len(rows) >= 1, "Account A should see its own trees" -@pytest.mark.asyncio async def test_trees_no_context_sees_no_private_trees(conn_no_context): rows = await conn_no_context.fetch( "SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE" @@ -219,7 +222,6 @@ async def test_trees_no_context_sees_no_private_trees(conn_no_context): # tree_tags — platform visibility # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a): rows = await conn_a.fetch( f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'" @@ -227,7 +229,6 @@ async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a): assert len(rows) == 0 -@pytest.mark.asyncio async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b): rows_a = await conn_a.fetch( f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'" @@ -243,7 +244,6 @@ async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b): # tree_categories — platform visibility # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_tree_categories_account_a_cannot_see_account_b(conn_a): rows = await conn_a.fetch( f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'" @@ -255,7 +255,6 @@ async def test_tree_categories_account_a_cannot_see_account_b(conn_a): # step_categories — platform visibility # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_step_categories_account_a_cannot_see_account_b(conn_a): rows = await conn_a.fetch( f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'" @@ -267,7 +266,6 @@ async def test_step_categories_account_a_cannot_see_account_b(conn_a): # psa_connections — tenant-only # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_psa_connections_account_a_cannot_see_account_b(conn_a): rows = await conn_a.fetch( f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'" @@ -279,7 +277,6 @@ async def test_psa_connections_account_a_cannot_see_account_b(conn_a): # flow_proposals — tenant-only # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_flow_proposals_account_a_cannot_see_account_b(conn_a): rows = await conn_a.fetch( f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'" @@ -513,7 +510,6 @@ async def session_row_ids(admin_conn): # sessions # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_sessions_account_a_cannot_see_account_b_sessions(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_b']}'" @@ -521,7 +517,6 @@ async def test_sessions_account_a_cannot_see_account_b_sessions(conn_a, session_ assert len(rows) == 0, "Account A should not see Account B sessions" -@pytest.mark.asyncio async def test_sessions_account_a_can_see_own_sessions(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_a']}'" @@ -529,7 +524,6 @@ async def test_sessions_account_a_can_see_own_sessions(conn_a, session_row_ids): assert len(rows) == 1, "Account A should see its own sessions" -@pytest.mark.asyncio async def test_sessions_no_context_sees_nothing(conn_no_context, session_row_ids): rows = await conn_no_context.fetch( f"SELECT id FROM sessions WHERE id IN " @@ -542,7 +536,6 @@ async def test_sessions_no_context_sees_nothing(conn_no_context, session_row_ids # ai_sessions # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_ai_sessions_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_b']}'" @@ -550,7 +543,6 @@ async def test_ai_sessions_account_a_cannot_see_account_b(conn_a, session_row_id assert len(rows) == 0, "Account A should not see Account B ai_sessions" -@pytest.mark.asyncio async def test_ai_sessions_account_a_can_see_own(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_a']}'" @@ -562,7 +554,6 @@ async def test_ai_sessions_account_a_can_see_own(conn_a, session_row_ids): # session_branches # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_session_branches_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM session_branches WHERE account_id = '{ACCOUNT_B_ID}'" @@ -574,7 +565,6 @@ async def test_session_branches_account_a_cannot_see_account_b(conn_a, session_r # session_supporting_data # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_session_supporting_data_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM session_supporting_data WHERE account_id = '{ACCOUNT_B_ID}'" @@ -586,7 +576,6 @@ async def test_session_supporting_data_account_a_cannot_see_account_b(conn_a, se # session_resolution_outputs # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_session_resolution_outputs_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM session_resolution_outputs WHERE account_id = '{ACCOUNT_B_ID}'" @@ -598,7 +587,6 @@ async def test_session_resolution_outputs_account_a_cannot_see_account_b(conn_a, # session_handoffs # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_session_handoffs_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM session_handoffs WHERE account_id = '{ACCOUNT_B_ID}'" @@ -610,7 +598,6 @@ async def test_session_handoffs_account_a_cannot_see_account_b(conn_a, session_r # script_templates # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_script_templates_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM script_templates WHERE account_id = '{ACCOUNT_B_ID}'" @@ -622,7 +609,6 @@ async def test_script_templates_account_a_cannot_see_account_b(conn_a, session_r # script_generations # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_script_generations_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM script_generations WHERE account_id = '{ACCOUNT_B_ID}'" @@ -634,7 +620,6 @@ async def test_script_generations_account_a_cannot_see_account_b(conn_a, session # maintenance_schedules # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_maintenance_schedules_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM maintenance_schedules WHERE account_id = '{ACCOUNT_B_ID}'" @@ -646,7 +631,6 @@ async def test_maintenance_schedules_account_a_cannot_see_account_b(conn_a, sess # psa_post_log # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_ids): rows = await conn_a.fetch( f"SELECT id FROM psa_post_log WHERE account_id = '{ACCOUNT_B_ID}'" @@ -658,7 +642,6 @@ async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_i # step_library — visibility-aware policy # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a): """Private/non-public steps owned by Account B must not be visible to Account A.""" private_step_id = str(uuid.uuid4()) @@ -683,7 +666,6 @@ async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_c ) -@pytest.mark.asyncio async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a): """Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility).""" public_step_id = str(uuid.uuid4()) @@ -738,7 +720,6 @@ async def _get_tree_b_id(admin_conn) -> str: # step_ratings # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see step ratings belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -779,7 +760,6 @@ async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a): # step_usage_log # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see step usage logs belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -832,7 +812,6 @@ async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a) # target_lists # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_target_lists_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see target lists belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -859,7 +838,6 @@ async def test_target_lists_account_a_cannot_see_account_b(admin_conn, conn_a): # session_shares # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_session_shares_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see session shares belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -903,7 +881,6 @@ async def test_session_shares_account_a_cannot_see_account_b(admin_conn, conn_a) # audit_logs # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_audit_logs_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see audit logs belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -930,7 +907,6 @@ async def test_audit_logs_account_a_cannot_see_account_b(admin_conn, conn_a): # tree_shares # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_tree_shares_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see tree shares belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -968,7 +944,6 @@ async def test_tree_shares_account_a_cannot_see_account_b(admin_conn, conn_a): # users # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_users_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see users belonging to Account B.""" rows = await conn_a.fetch( @@ -977,7 +952,6 @@ async def test_users_account_a_cannot_see_account_b(admin_conn, conn_a): assert len(rows) == 0, "Account A should not see Account B users" -@pytest.mark.asyncio async def test_users_account_a_can_see_own(admin_conn, conn_a): """Account A must be able to see its own users.""" rows = await conn_a.fetch( @@ -990,7 +964,6 @@ async def test_users_account_a_can_see_own(admin_conn, conn_a): # script_builder_sessions # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see script builder sessions belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -1019,7 +992,6 @@ async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn # ai_session_steps # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see ai_session_steps belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn) @@ -1061,7 +1033,6 @@ async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_ # notifications # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a): """Account A must not see notifications belonging to Account B.""" user_b_id = await _get_user_b_id(admin_conn)