test_basic_info_tenant_ownership.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. """
  2. Tests for tenant ownership validation in basic-info CRUD operations.
  3. Per AGENTS.md: "All business queries SHALL filter by current user's tenant_no."
  4. Per audit: customer, cell_sample_type, collection_protocol_no, sample_code
  5. CRUD get_*() functions do NOT filter by tenant_no, allowing cross-tenant
  6. update/delete if the record ID is known.
  7. These tests verify that:
  8. 1. get_*() functions accept and filter by tenant_no
  9. 2. update_*() functions reject cross-tenant modifications
  10. 3. soft_delete_*() functions reject cross-tenant deletions
  11. The collection_region module already correctly implements tenant_no filtering
  12. and serves as the reference pattern.
  13. """
  14. import unittest
  15. from unittest.mock import MagicMock, patch
  16. # We test the CRUD function signatures and behavior without a real database
  17. # by patching the SQLAlchemy session and model queries.
  18. class TestCustomerTenantOwnership(unittest.TestCase):
  19. """Customer CRUD should enforce tenant_no on update/delete."""
  20. def test_get_customer_signature_accepts_tenant_no(self):
  21. """get_customer() should accept an optional tenant_no parameter."""
  22. from app.crud.customer import get_customer
  23. import inspect
  24. sig = inspect.signature(get_customer)
  25. params = list(sig.parameters.keys())
  26. # Currently: ['db', 'customer_id'] — should add 'tenant_no'
  27. self.assertIn("tenant_no", params, "get_customer() must accept tenant_no parameter")
  28. def test_get_customer_filters_by_tenant_no(self):
  29. """get_customer() should filter query by tenant_no when provided."""
  30. from app.crud.customer import get_customer
  31. mock_db = MagicMock()
  32. mock_query = MagicMock()
  33. mock_db.query.return_value = mock_query
  34. mock_query.filter.return_value = mock_query
  35. mock_query.first.return_value = None
  36. # Should not raise, and should add tenant_no filter
  37. result = get_customer(mock_db, "some-id", tenant_no="tenant-A")
  38. mock_db.query.assert_called_once()
  39. # filter() should be called at least twice (del_flag + tenant_no)
  40. self.assertGreaterEqual(mock_query.filter.call_count, 2)
  41. def test_update_customer_rejects_cross_tenant(self):
  42. """update_customer() should raise when record belongs to different tenant."""
  43. from app.crud.customer import update_customer
  44. mock_db = MagicMock()
  45. mock_query = MagicMock()
  46. mock_db.query.return_value = mock_query
  47. mock_query.filter.return_value = mock_query
  48. mock_query.first.return_value = None # tenant_no filter blocks access
  49. with self.assertRaises(ValueError) as ctx:
  50. update_customer(
  51. mock_db,
  52. "cross-tenant-id",
  53. customer_name="Hacked",
  54. user_id="user-A",
  55. tenant_no="tenant-A", # record belongs to tenant-B
  56. )
  57. self.assertIn("不存在", str(ctx.exception))
  58. def test_soft_delete_customer_rejects_cross_tenant(self):
  59. """soft_delete_customer() should raise when record belongs to different tenant."""
  60. from app.crud.customer import soft_delete_customer
  61. mock_db = MagicMock()
  62. mock_query = MagicMock()
  63. mock_db.query.return_value = mock_query
  64. mock_query.filter.return_value = mock_query
  65. mock_query.first.return_value = None # tenant_no filter blocks access
  66. with self.assertRaises(ValueError):
  67. soft_delete_customer(mock_db, "cross-tenant-id", tenant_no="tenant-A")
  68. class TestCellSampleTypeTenantOwnership(unittest.TestCase):
  69. """CellSampleType CRUD should enforce tenant_no on update/delete."""
  70. def test_get_cell_sample_type_signature_accepts_tenant_no(self):
  71. """get_cell_sample_type() should accept an optional tenant_no parameter."""
  72. from app.crud.cell_sample_type import get_cell_sample_type
  73. import inspect
  74. sig = inspect.signature(get_cell_sample_type)
  75. params = list(sig.parameters.keys())
  76. self.assertIn("tenant_no", params, "get_cell_sample_type() must accept tenant_no parameter")
  77. def test_update_cell_sample_type_rejects_cross_tenant(self):
  78. """update_cell_sample_type() should raise when record belongs to different tenant."""
  79. from app.crud.cell_sample_type import update_cell_sample_type
  80. mock_db = MagicMock()
  81. mock_query = MagicMock()
  82. mock_db.query.return_value = mock_query
  83. mock_query.filter.return_value = mock_query
  84. mock_query.first.return_value = None
  85. with self.assertRaises(ValueError) as ctx:
  86. update_cell_sample_type(
  87. mock_db,
  88. "cross-tenant-id",
  89. type_name="Hacked",
  90. user_id="user-A",
  91. tenant_no="tenant-A",
  92. )
  93. self.assertIn("不存在", str(ctx.exception))
  94. def test_soft_delete_cell_sample_type_rejects_cross_tenant(self):
  95. """soft_delete_cell_sample_type() should raise when record belongs to different tenant."""
  96. from app.crud.cell_sample_type import soft_delete_cell_sample_type
  97. mock_db = MagicMock()
  98. mock_query = MagicMock()
  99. mock_db.query.return_value = mock_query
  100. mock_query.filter.return_value = mock_query
  101. mock_query.first.return_value = None
  102. with self.assertRaises(ValueError):
  103. soft_delete_cell_sample_type(mock_db, "cross-tenant-id", tenant_no="tenant-A")
  104. class TestProtocolNoTenantOwnership(unittest.TestCase):
  105. """CollectionProtocolNo CRUD should enforce tenant_no on update/delete."""
  106. def test_get_protocol_no_signature_accepts_tenant_no(self):
  107. """get_protocol_no() should accept an optional tenant_no parameter."""
  108. from app.crud.collection_protocol_no import get_protocol_no
  109. import inspect
  110. sig = inspect.signature(get_protocol_no)
  111. params = list(sig.parameters.keys())
  112. self.assertIn("tenant_no", params, "get_protocol_no() must accept tenant_no parameter")
  113. def test_update_protocol_no_rejects_cross_tenant(self):
  114. """update_protocol_no() should raise when record belongs to different tenant."""
  115. from app.crud.collection_protocol_no import update_protocol_no
  116. mock_db = MagicMock()
  117. mock_query = MagicMock()
  118. mock_db.query.return_value = mock_query
  119. mock_query.filter.return_value = mock_query
  120. mock_query.first.return_value = None
  121. with self.assertRaises(ValueError) as ctx:
  122. update_protocol_no(
  123. mock_db,
  124. "cross-tenant-id",
  125. remark="Hacked",
  126. user_id="user-A",
  127. tenant_no="tenant-A",
  128. )
  129. self.assertIn("不存在", str(ctx.exception))
  130. def test_soft_delete_protocol_no_rejects_cross_tenant(self):
  131. """soft_delete_protocol_no() should raise when record belongs to different tenant."""
  132. from app.crud.collection_protocol_no import soft_delete_protocol_no
  133. mock_db = MagicMock()
  134. mock_query = MagicMock()
  135. mock_db.query.return_value = mock_query
  136. mock_query.filter.return_value = mock_query
  137. mock_query.first.return_value = None
  138. with self.assertRaises(ValueError):
  139. soft_delete_protocol_no(mock_db, "cross-tenant-id", tenant_no="tenant-A")
  140. class TestSampleCodeTenantOwnership(unittest.TestCase):
  141. """SampleCode CRUD should enforce tenant_no on update/delete."""
  142. def test_get_sample_code_signature_accepts_tenant_no(self):
  143. """get_sample_code() should accept an optional tenant_no parameter."""
  144. from app.crud.sample_code import get_sample_code
  145. import inspect
  146. sig = inspect.signature(get_sample_code)
  147. params = list(sig.parameters.keys())
  148. self.assertIn("tenant_no", params, "get_sample_code() must accept tenant_no parameter")
  149. def test_update_sample_code_rejects_cross_tenant(self):
  150. """update_sample_code() should raise when record belongs to different tenant."""
  151. from app.crud.sample_code import update_sample_code
  152. mock_db = MagicMock()
  153. mock_query = MagicMock()
  154. mock_db.query.return_value = mock_query
  155. mock_query.filter.return_value = mock_query
  156. mock_query.first.return_value = None
  157. with self.assertRaises(ValueError) as ctx:
  158. update_sample_code(
  159. mock_db,
  160. "cross-tenant-id",
  161. remark="Hacked",
  162. user_id="user-A",
  163. tenant_no="tenant-A",
  164. )
  165. self.assertIn("不存在", str(ctx.exception))
  166. def test_soft_delete_sample_code_rejects_cross_tenant(self):
  167. """soft_delete_sample_code() should raise when record belongs to different tenant."""
  168. from app.crud.sample_code import soft_delete_sample_code
  169. mock_db = MagicMock()
  170. mock_query = MagicMock()
  171. mock_db.query.return_value = mock_query
  172. mock_query.filter.return_value = mock_query
  173. mock_query.first.return_value = None
  174. with self.assertRaises(ValueError):
  175. soft_delete_sample_code(mock_db, "cross-tenant-id", tenant_no="tenant-A")
  176. class TestGetTenantNoFallback(unittest.TestCase):
  177. """_get_tenant_no() should not silently fall back to hardcoded value."""
  178. def test_get_tenant_no_raises_on_missing_user(self):
  179. """When user_id is missing from token, should raise rather than default."""
  180. from app.api.basic_info import _get_tenant_no
  181. mock_db = MagicMock()
  182. user = {} # no user_id
  183. with self.assertRaises((ValueError, PermissionError)):
  184. _get_tenant_no(mock_db, user)
  185. def test_get_tenant_no_raises_on_missing_sys_user(self):
  186. """When SysUser record not found in DB, should raise rather than default."""
  187. from app.api.basic_info import _get_tenant_no
  188. mock_db = MagicMock()
  189. mock_query = MagicMock()
  190. mock_db.query.return_value = mock_query
  191. mock_query.filter.return_value = mock_query
  192. mock_query.first.return_value = None # SysUser not found
  193. user = {"user_id": "some-id"}
  194. with self.assertRaises((ValueError, PermissionError)):
  195. _get_tenant_no(mock_db, user)
  196. if __name__ == "__main__":
  197. unittest.main()