| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- """
- Tests for tenant ownership validation in basic-info CRUD operations.
- Per AGENTS.md: "All business queries SHALL filter by current user's tenant_no."
- Per audit: customer, cell_sample_type, collection_protocol_no, sample_code
- CRUD get_*() functions do NOT filter by tenant_no, allowing cross-tenant
- update/delete if the record ID is known.
- These tests verify that:
- 1. get_*() functions accept and filter by tenant_no
- 2. update_*() functions reject cross-tenant modifications
- 3. soft_delete_*() functions reject cross-tenant deletions
- The collection_region module already correctly implements tenant_no filtering
- and serves as the reference pattern.
- """
- import unittest
- from unittest.mock import MagicMock, patch
- # We test the CRUD function signatures and behavior without a real database
- # by patching the SQLAlchemy session and model queries.
- class TestCustomerTenantOwnership(unittest.TestCase):
- """Customer CRUD should enforce tenant_no on update/delete."""
- def test_get_customer_signature_accepts_tenant_no(self):
- """get_customer() should accept an optional tenant_no parameter."""
- from app.crud.customer import get_customer
- import inspect
- sig = inspect.signature(get_customer)
- params = list(sig.parameters.keys())
- # Currently: ['db', 'customer_id'] — should add 'tenant_no'
- self.assertIn("tenant_no", params, "get_customer() must accept tenant_no parameter")
- def test_get_customer_filters_by_tenant_no(self):
- """get_customer() should filter query by tenant_no when provided."""
- from app.crud.customer import get_customer
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- # Should not raise, and should add tenant_no filter
- result = get_customer(mock_db, "some-id", tenant_no="tenant-A")
- mock_db.query.assert_called_once()
- # filter() should be called at least twice (del_flag + tenant_no)
- self.assertGreaterEqual(mock_query.filter.call_count, 2)
- def test_update_customer_rejects_cross_tenant(self):
- """update_customer() should raise when record belongs to different tenant."""
- from app.crud.customer import update_customer
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None # tenant_no filter blocks access
- with self.assertRaises(ValueError) as ctx:
- update_customer(
- mock_db,
- "cross-tenant-id",
- customer_name="Hacked",
- user_id="user-A",
- tenant_no="tenant-A", # record belongs to tenant-B
- )
- self.assertIn("不存在", str(ctx.exception))
- def test_soft_delete_customer_rejects_cross_tenant(self):
- """soft_delete_customer() should raise when record belongs to different tenant."""
- from app.crud.customer import soft_delete_customer
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None # tenant_no filter blocks access
- with self.assertRaises(ValueError):
- soft_delete_customer(mock_db, "cross-tenant-id", tenant_no="tenant-A")
- class TestCellSampleTypeTenantOwnership(unittest.TestCase):
- """CellSampleType CRUD should enforce tenant_no on update/delete."""
- def test_get_cell_sample_type_signature_accepts_tenant_no(self):
- """get_cell_sample_type() should accept an optional tenant_no parameter."""
- from app.crud.cell_sample_type import get_cell_sample_type
- import inspect
- sig = inspect.signature(get_cell_sample_type)
- params = list(sig.parameters.keys())
- self.assertIn("tenant_no", params, "get_cell_sample_type() must accept tenant_no parameter")
- def test_update_cell_sample_type_rejects_cross_tenant(self):
- """update_cell_sample_type() should raise when record belongs to different tenant."""
- from app.crud.cell_sample_type import update_cell_sample_type
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- with self.assertRaises(ValueError) as ctx:
- update_cell_sample_type(
- mock_db,
- "cross-tenant-id",
- type_name="Hacked",
- user_id="user-A",
- tenant_no="tenant-A",
- )
- self.assertIn("不存在", str(ctx.exception))
- def test_soft_delete_cell_sample_type_rejects_cross_tenant(self):
- """soft_delete_cell_sample_type() should raise when record belongs to different tenant."""
- from app.crud.cell_sample_type import soft_delete_cell_sample_type
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- with self.assertRaises(ValueError):
- soft_delete_cell_sample_type(mock_db, "cross-tenant-id", tenant_no="tenant-A")
- class TestProtocolNoTenantOwnership(unittest.TestCase):
- """CollectionProtocolNo CRUD should enforce tenant_no on update/delete."""
- def test_get_protocol_no_signature_accepts_tenant_no(self):
- """get_protocol_no() should accept an optional tenant_no parameter."""
- from app.crud.collection_protocol_no import get_protocol_no
- import inspect
- sig = inspect.signature(get_protocol_no)
- params = list(sig.parameters.keys())
- self.assertIn("tenant_no", params, "get_protocol_no() must accept tenant_no parameter")
- def test_update_protocol_no_rejects_cross_tenant(self):
- """update_protocol_no() should raise when record belongs to different tenant."""
- from app.crud.collection_protocol_no import update_protocol_no
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- with self.assertRaises(ValueError) as ctx:
- update_protocol_no(
- mock_db,
- "cross-tenant-id",
- remark="Hacked",
- user_id="user-A",
- tenant_no="tenant-A",
- )
- self.assertIn("不存在", str(ctx.exception))
- def test_soft_delete_protocol_no_rejects_cross_tenant(self):
- """soft_delete_protocol_no() should raise when record belongs to different tenant."""
- from app.crud.collection_protocol_no import soft_delete_protocol_no
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- with self.assertRaises(ValueError):
- soft_delete_protocol_no(mock_db, "cross-tenant-id", tenant_no="tenant-A")
- class TestSampleCodeTenantOwnership(unittest.TestCase):
- """SampleCode CRUD should enforce tenant_no on update/delete."""
- def test_get_sample_code_signature_accepts_tenant_no(self):
- """get_sample_code() should accept an optional tenant_no parameter."""
- from app.crud.sample_code import get_sample_code
- import inspect
- sig = inspect.signature(get_sample_code)
- params = list(sig.parameters.keys())
- self.assertIn("tenant_no", params, "get_sample_code() must accept tenant_no parameter")
- def test_update_sample_code_rejects_cross_tenant(self):
- """update_sample_code() should raise when record belongs to different tenant."""
- from app.crud.sample_code import update_sample_code
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- with self.assertRaises(ValueError) as ctx:
- update_sample_code(
- mock_db,
- "cross-tenant-id",
- remark="Hacked",
- user_id="user-A",
- tenant_no="tenant-A",
- )
- self.assertIn("不存在", str(ctx.exception))
- def test_soft_delete_sample_code_rejects_cross_tenant(self):
- """soft_delete_sample_code() should raise when record belongs to different tenant."""
- from app.crud.sample_code import soft_delete_sample_code
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None
- with self.assertRaises(ValueError):
- soft_delete_sample_code(mock_db, "cross-tenant-id", tenant_no="tenant-A")
- class TestGetTenantNoFallback(unittest.TestCase):
- """_get_tenant_no() should not silently fall back to hardcoded value."""
- def test_get_tenant_no_raises_on_missing_user(self):
- """When user_id is missing from token, should raise rather than default."""
- from app.api.basic_info import _get_tenant_no
- mock_db = MagicMock()
- user = {} # no user_id
- with self.assertRaises((ValueError, PermissionError)):
- _get_tenant_no(mock_db, user)
- def test_get_tenant_no_raises_on_missing_sys_user(self):
- """When SysUser record not found in DB, should raise rather than default."""
- from app.api.basic_info import _get_tenant_no
- mock_db = MagicMock()
- mock_query = MagicMock()
- mock_db.query.return_value = mock_query
- mock_query.filter.return_value = mock_query
- mock_query.first.return_value = None # SysUser not found
- user = {"user_id": "some-id"}
- with self.assertRaises((ValueError, PermissionError)):
- _get_tenant_no(mock_db, user)
- if __name__ == "__main__":
- unittest.main()
|