157 lines
8.6 KiB
Python

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