# pyrefly: ignore [missing-import] from odoo.tests.common import TransactionCase # pyrefly: ignore [missing-import] from odoo.exceptions import AccessError, ValidationError class TestHostelSecurity(TransactionCase): """Phase K — Hostel security and IDOR protection tests. Tests 14-20: ORM-level access control and record isolation. IDOR (Insecure Direct Object Reference) testing principle: When student B (portal user) tries to browse student A's member record ID, the record rule [('student_id.portal_user_id', '=', user.id)] will return an empty recordset — functionally equivalent to a 404 from the portal. """ @classmethod def setUpClass(cls): super().setUpClass() # Shared infrastructure cls.campus = cls.env['education.campus'].create({'name': 'SEC Campus', 'code': 'SCC', 'company_id': cls.env.company.id}) cls.academic_year = cls.env['education.academic.year'].create({'name': 'AY-SEC', 'code': 'AYSEC', 'start_date': '2025-06-01', 'end_date': '2026-05-31', 'company_id': cls.env.company.id}) cls.academic_term = cls.env['education.academic.term'].create({'name': 'T-SEC', 'code': 'TSEC', 'academic_year_id': cls.academic_year.id, 'start_date': '2025-06-01', 'end_date': '2025-11-30', 'sequence': 1}) cls.department = cls.env['education.department'].create({'name': 'SEC Dept', 'code': 'SED', 'campus_id': cls.campus.id, 'company_id': cls.env.company.id}) cls.level = cls.env['education.academic.level'].create({'name': 'UG-SEC', 'code': 'UGSC', 'company_id': cls.env.company.id}) cls.program = cls.env['education.program'].create({'name': 'SEC Prog', 'code': 'SEP', 'department_id': cls.department.id, 'level_id': cls.level.id, 'company_id': cls.env.company.id}) cls.batch = cls.env['education.batch'].create({'name': 'BS', 'code': 'BS', 'company_id': cls.env.company.id}) cls.semester = cls.env['education.semester'].create({'name': 'SS', 'code': 'SS', 'company_id': cls.env.company.id}) cls.section = cls.env['education.section'].create({'name': 'S', 'code': 'SS', 'company_id': cls.env.company.id}) cls.hostel = cls.env['education.hostel'].create({'name': 'Sec Hostel', 'code': 'SHH', 'campus_id': cls.campus.id, 'hostel_type': 'mixed', 'company_id': cls.env.company.id}) cls.floor = cls.env['education.hostel.floor'].create({'hostel_id': cls.hostel.id, 'name': 'F1', 'code': 'F1', 'sequence': 1}) cls.room = cls.env['education.hostel.room'].create({'hostel_id': cls.hostel.id, 'floor_id': cls.floor.id, 'name': 'R100', 'room_number': '100', 'room_type': 'double', 'capacity': 2, 'gender': 'mixed'}) cls.bed_a = cls.env['education.hostel.bed'].create({'room_id': cls.room.id, 'bed_number': 'A', 'code': 'SHH-100-A', 'status': 'available'}) cls.bed_b = cls.env['education.hostel.bed'].create({'room_id': cls.room.id, 'bed_number': 'B', 'code': 'SHH-100-B', 'status': 'available'}) # Create portal users portal_group = cls.env.ref('base.group_portal') def _make_portal_user(name, email): partner = cls.env['res.partner'].create({'name': name, 'email': email}) return cls.env['res.users'].create({'name': name, 'login': email, 'email': email, 'partner_id': partner.id, 'group_ids': [(6, 0, [portal_group.id])]}) cls.user_a = _make_portal_user('Student A Portal', 'student_a_sec@test.edu') cls.user_b = _make_portal_user('Student B Portal', 'student_b_sec@test.edu') def _make_student(first, last, email, user): s = cls.env['education.student'].create({ 'first_name': first, 'last_name': last, 'gender': 'male', 'campus_id': cls.campus.id, 'department_id': cls.department.id, 'program_id': cls.program.id, 'level_id': cls.level.id, 'academic_year_id': cls.academic_year.id, 'academic_term_id': cls.academic_term.id, 'batch_id': cls.batch.id, 'semester_id': cls.semester.id, 'section_id': cls.section.id, 'company_id': cls.env.company.id, 'email': email, }) s.portal_user_id = user return s cls.student_a = _make_student('SecA', 'StudentA', 'student_a_sec@test.edu', cls.user_a) cls.student_b = _make_student('SecB', 'StudentB', 'student_b_sec@test.edu', cls.user_b) # Create active allocation for student A cls.member_a = cls.env['education.hostel.member'].create({ 'student_id': cls.student_a.id, 'hostel_id': cls.hostel.id, 'room_id': cls.room.id, 'bed_id': cls.bed_a.id, 'allocation_date': '2025-06-01', }) cls.member_a.action_activate() # 14. Portal Access — Student Sees Own Member Record def test_14_portal_own_member_visible(self): member_as_user_a = self.env['education.hostel.member'].with_user(self.user_a).browse(self.member_a.id) # Should be accessible (record rule matches) self.assertEqual(member_as_user_a.id, self.member_a.id) # 15. Student A/B Isolation (ORM) — IDOR Test def test_15_student_b_cannot_see_student_a_member(self): """Student B searching student A's member ID must get empty recordset.""" member_as_user_b = self.env['education.hostel.member'].with_user(self.user_b).search([('id', '=', self.member_a.id)]) # Record rule returns empty recordset self.assertEqual(len(member_as_user_b), 0) # 16. IDOR Member — browse(id) with wrong user def test_16_idor_member_orm(self): """Attempting to read student A's allocation as student B returns empty.""" members = self.env['education.hostel.member'].with_user(self.user_b).search([ ('id', '=', self.member_a.id), ]) self.assertEqual(len(members), 0) # 17. IDOR Complaint — Student B can't access Student A's complaint def test_17_idor_complaint_orm(self): complaint_a = self.env['education.hostel.complaint'].create({ 'student_id': self.student_a.id, 'hostel_id': self.hostel.id, 'category': 'maintenance', 'title': 'Test complaint', }) complaints_as_b = self.env['education.hostel.complaint'].with_user(self.user_b).search([ ('id', '=', complaint_a.id), ]) self.assertEqual(len(complaints_as_b), 0) # 18. IDOR Fee — Student B can't access Student A's fee def test_18_idor_fee_orm(self): fee_a = self.env['education.hostel.fee'].create({ 'student_id': self.student_a.id, 'hostel_id': self.hostel.id, 'amount': 5000.0, 'due_date': '2025-07-31', }) fees_as_b = self.env['education.hostel.fee'].with_user(self.user_b).search([ ('id', '=', fee_a.id), ]) self.assertEqual(len(fees_as_b), 0) # 19. Manager Permissions def test_19_manager_permissions(self): manager_group = self.env.ref('education_hostel.group_hostel_manager') manager_user = self.env['res.users'].create({ 'name': 'Hostel Manager', 'login': 'hostel_mgr@test.edu', 'group_ids': [(6, 0, [manager_group.id, self.env.ref('base.group_user').id])], }) # Manager can create hostel fee structures fs = self.env['education.hostel.fee.structure'].with_user(manager_user).create({ 'hostel_id': self.hostel.id, 'room_type': 'single', 'amount': 3000.0, 'billing_period': 'quarterly', }) self.assertTrue(fs.id) # 20. Warden Permissions def test_20_warden_permissions(self): warden_group = self.env.ref('education_hostel.group_hostel_warden') warden_user = self.env['res.users'].create({ 'name': 'Warden User', 'login': 'warden@test.edu', 'group_ids': [(6, 0, [warden_group.id, self.env.ref('base.group_user').id])], }) # Warden can create member (allocation) member = self.env['education.hostel.member'].with_user(warden_user).create({ 'student_id': self.student_b.id, 'hostel_id': self.hostel.id, 'room_id': self.room.id, 'bed_id': self.bed_b.id, 'allocation_date': '2025-06-15', }) self.assertEqual(member.status, 'pending') # Warden should NOT be able to unlink (only admin can) user_group = self.env.ref('education_hostel.group_hostel_user') user_only = self.env['res.users'].create({ 'name': 'Hostel Basic User', 'login': 'hostel_basic@test.edu', 'group_ids': [(6, 0, [user_group.id, self.env.ref('base.group_user').id])], }) with self.assertRaises(AccessError): member.with_user(user_only).unlink()