2025-07-11 21:47:50 -04:00
|
|
|
from odoo.tests import HttpCase, tagged
|
|
|
|
|
from odoo.exceptions import AccessError
|
|
|
|
|
from odoo import Command, fields
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@tagged("-at_install", "post_install")
|
|
|
|
|
class TestSecurityIntegration(HttpCase):
|
|
|
|
|
"""Integration tests for the sports clinic security features"""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
super().setUpClass()
|
|
|
|
|
|
|
|
|
|
# Create organization and team
|
|
|
|
|
cls.organization = cls.env['sports.organization'].create({
|
|
|
|
|
'name': 'Test Security Organization',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cls.team = cls.env['sports.team'].create({
|
|
|
|
|
'name': 'Test Security Team',
|
|
|
|
|
'organization_id': cls.organization.id,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Create some patients/players
|
|
|
|
|
cls.patient1 = cls.env['sports.patient'].create({
|
|
|
|
|
'first_name': 'Security',
|
|
|
|
|
'last_name': 'Test Patient',
|
|
|
|
|
'birthdate': '2005-01-01',
|
|
|
|
|
'team_ids': [(4, cls.team.id)],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Create an active injury for patient1
|
|
|
|
|
cls.existing_injury = cls.env['sports.patient.injury'].create({
|
|
|
|
|
'patient_id': cls.patient1.id,
|
|
|
|
|
'team_id': cls.team.id,
|
|
|
|
|
'diagnosis': 'Security Test Injury',
|
|
|
|
|
'stage': 'active',
|
|
|
|
|
'injury_date': fields.Date.today(),
|
|
|
|
|
'internal_notes': 'These are internal notes for security testing',
|
|
|
|
|
'external_notes': 'These are external notes for security testing',
|
|
|
|
|
'parental_consent': 'yes',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Create a second team that will not have our test users as staff
|
|
|
|
|
cls.restricted_team = cls.env['sports.team'].create({
|
|
|
|
|
'name': 'Restricted Team',
|
|
|
|
|
'organization_id': cls.organization.id,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cls.restricted_patient = cls.env['sports.patient'].create({
|
|
|
|
|
'first_name': 'Restricted',
|
|
|
|
|
'last_name': 'Patient',
|
|
|
|
|
'birthdate': '2006-02-02',
|
|
|
|
|
'team_ids': [(4, cls.restricted_team.id)],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cls.restricted_injury = cls.env['sports.patient.injury'].create({
|
|
|
|
|
'patient_id': cls.restricted_patient.id,
|
|
|
|
|
'team_id': cls.restricted_team.id,
|
|
|
|
|
'diagnosis': 'Restricted Injury',
|
|
|
|
|
'stage': 'active',
|
|
|
|
|
'injury_date': fields.Date.today(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Create users with different roles
|
|
|
|
|
# 1. Therapist (treatment professional)
|
|
|
|
|
cls.therapist_partner = cls.env['res.partner'].create({
|
|
|
|
|
'name': 'Security Therapist',
|
|
|
|
|
'email': 'security.therapist@example.com',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cls.therapist_user = cls.env['res.users'].with_context(no_reset_password=True).create({
|
|
|
|
|
'partner_id': cls.therapist_partner.id,
|
|
|
|
|
'login': 'security.therapist@example.com',
|
|
|
|
|
'password': 'therapist123',
|
|
|
|
|
'name': cls.therapist_partner.name,
|
|
|
|
|
'groups_id': [
|
|
|
|
|
Command.link(cls.env.ref('base.group_portal').id),
|
|
|
|
|
Command.link(cls.env.ref('bemade_sports_clinic.group_portal_treatment_professional').id),
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# 2. Coach
|
|
|
|
|
cls.coach_partner = cls.env['res.partner'].create({
|
|
|
|
|
'name': 'Security Coach',
|
|
|
|
|
'email': 'security.coach@example.com',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cls.coach_user = cls.env['res.users'].with_context(no_reset_password=True).create({
|
|
|
|
|
'partner_id': cls.coach_partner.id,
|
|
|
|
|
'login': 'security.coach@example.com',
|
|
|
|
|
'password': 'coach123',
|
|
|
|
|
'name': cls.coach_partner.name,
|
|
|
|
|
'groups_id': [
|
|
|
|
|
Command.link(cls.env.ref('base.group_portal').id),
|
|
|
|
|
Command.link(cls.env.ref('bemade_sports_clinic.group_portal_team_coach').id),
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Create team staff entries for the main test team only
|
|
|
|
|
cls.env['sports.team.staff'].create({
|
|
|
|
|
'team_id': cls.team.id,
|
|
|
|
|
'partner_id': cls.therapist_partner.id,
|
2025-07-16 11:37:50 -04:00
|
|
|
# Role therapist automatically grants treatment professional status
|
2025-07-11 21:47:50 -04:00
|
|
|
'role': 'therapist',
|
|
|
|
|
'user_id': cls.therapist_user.id,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cls.env['sports.team.staff'].create({
|
|
|
|
|
'team_id': cls.team.id,
|
|
|
|
|
'partner_id': cls.coach_partner.id,
|
2025-07-16 11:37:50 -04:00
|
|
|
# Role coach doesn't grant treatment professional status
|
2025-07-11 21:47:50 -04:00
|
|
|
'role': 'coach',
|
|
|
|
|
'user_id': cls.coach_user.id,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def test_01_field_level_security_for_therapist(self):
|
|
|
|
|
"""Test field-level security validation for therapist users"""
|
|
|
|
|
# Login as therapist
|
|
|
|
|
self.authenticate('security.therapist@example.com', 'therapist123')
|
|
|
|
|
|
|
|
|
|
# Therapist should be able to access the patient injury page with internal notes
|
|
|
|
|
injury_response = self.url_open(f'/my/player/injury?injury_id={self.existing_injury.id}')
|
|
|
|
|
self.assertEqual(injury_response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
# Verify that therapist can see internal notes field
|
|
|
|
|
self.assertIn('Internal Notes', injury_response.text)
|
|
|
|
|
self.assertIn('These are internal notes for security testing', injury_response.text)
|
|
|
|
|
|
|
|
|
|
def test_02_field_level_security_for_coach(self):
|
|
|
|
|
"""Test field-level security validation for coach users"""
|
|
|
|
|
# Login as coach
|
|
|
|
|
self.authenticate('security.coach@example.com', 'coach123')
|
|
|
|
|
|
|
|
|
|
# Coach should be able to access the patient injury page but not see internal notes
|
|
|
|
|
injury_response = self.url_open(f'/my/player/injury?injury_id={self.existing_injury.id}')
|
|
|
|
|
self.assertEqual(injury_response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
# Verify that coach cannot see internal notes field or its content
|
|
|
|
|
self.assertNotIn('These are internal notes for security testing', injury_response.text)
|
|
|
|
|
|
|
|
|
|
# Check if the form access properly restricts the parental consent field
|
|
|
|
|
injury_form_response = self.url_open(f'/my/patient/injury/new?patient_id={self.patient1.id}')
|
|
|
|
|
self.assertEqual(injury_form_response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
# Verify parental consent field is not shown to coaches
|
|
|
|
|
self.assertNotIn('id="parental_consent"', injury_form_response.text)
|
|
|
|
|
|
|
|
|
|
def test_03_therapist_cannot_access_unauthorized_team(self):
|
|
|
|
|
"""Test that therapists cannot access teams they're not staff of"""
|
|
|
|
|
# Login as therapist
|
|
|
|
|
self.authenticate('security.therapist@example.com', 'therapist123')
|
|
|
|
|
|
|
|
|
|
# 1. Test that therapist can access authorized team
|
|
|
|
|
authorized_team_response = self.url_open(f'/my/team?team_id={self.team.id}')
|
|
|
|
|
self.assertEqual(authorized_team_response.status_code, 200)
|
|
|
|
|
self.assertIn(self.team.name, authorized_team_response.text)
|
|
|
|
|
|
|
|
|
|
# 2. Test that therapist cannot access unauthorized team
|
|
|
|
|
# This might redirect to a permission error page or to the teams list
|
|
|
|
|
restricted_team_response = self.url_open(f'/my/team?team_id={self.restricted_team.id}')
|
|
|
|
|
|
|
|
|
|
# Should either be an error page or not contain the restricted team name
|
|
|
|
|
if restricted_team_response.status_code == 200:
|
|
|
|
|
self.assertNotIn(self.restricted_team.name, restricted_team_response.text)
|
|
|
|
|
else:
|
|
|
|
|
self.assertIn(restricted_team_response.status_code, [403, 404])
|
|
|
|
|
|
|
|
|
|
def test_04_coach_cannot_access_unauthorized_team(self):
|
|
|
|
|
"""Test that coaches cannot access teams they're not staff of"""
|
|
|
|
|
# Login as coach
|
|
|
|
|
self.authenticate('security.coach@example.com', 'coach123')
|
|
|
|
|
|
|
|
|
|
# 1. Test that coach can access authorized team
|
|
|
|
|
authorized_team_response = self.url_open(f'/my/team?team_id={self.team.id}')
|
|
|
|
|
self.assertEqual(authorized_team_response.status_code, 200)
|
|
|
|
|
self.assertIn(self.team.name, authorized_team_response.text)
|
|
|
|
|
|
|
|
|
|
# 2. Test that coach cannot access unauthorized team
|
|
|
|
|
# This might redirect to a permission error page or to the teams list
|
|
|
|
|
restricted_team_response = self.url_open(f'/my/team?team_id={self.restricted_team.id}')
|
|
|
|
|
|
|
|
|
|
# Should either be an error page or not contain the restricted team name
|
|
|
|
|
if restricted_team_response.status_code == 200:
|
|
|
|
|
self.assertNotIn(self.restricted_team.name, restricted_team_response.text)
|
|
|
|
|
else:
|
|
|
|
|
self.assertIn(restricted_team_response.status_code, [403, 404])
|
|
|
|
|
|
|
|
|
|
def test_05_therapist_cannot_modify_unauthorized_injury(self):
|
|
|
|
|
"""Test that therapists cannot modify injuries from teams they're not staff of"""
|
|
|
|
|
# Login as therapist
|
|
|
|
|
self.authenticate('security.therapist@example.com', 'therapist123')
|
|
|
|
|
|
|
|
|
|
# Try to modify a restricted injury
|
|
|
|
|
# Prepare injury update data
|
|
|
|
|
update_data = {
|
|
|
|
|
'csrf_token': self.csrf_token(),
|
|
|
|
|
'injury_id': self.restricted_injury.id,
|
|
|
|
|
'diagnosis': 'Attempted Unauthorized Update',
|
|
|
|
|
'external_notes': 'This update should fail',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# This should fail or redirect
|
|
|
|
|
update_response = self.url_open(
|
|
|
|
|
'/my/patient/injury/update',
|
|
|
|
|
data=update_data,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Refresh the record from database to check if changes were saved
|
|
|
|
|
self.restricted_injury.invalidate_cache()
|
|
|
|
|
|
|
|
|
|
# Verify no changes were made
|
|
|
|
|
self.assertNotEqual(self.restricted_injury.diagnosis, 'Attempted Unauthorized Update')
|
|
|
|
|
|
|
|
|
|
def test_06_coach_cannot_modify_any_injury(self):
|
|
|
|
|
"""Test that coaches cannot modify any injury (they can only create)"""
|
|
|
|
|
# Login as coach
|
|
|
|
|
self.authenticate('security.coach@example.com', 'coach123')
|
|
|
|
|
|
|
|
|
|
# Try to modify an injury from their team
|
|
|
|
|
# Prepare injury update data
|
|
|
|
|
update_data = {
|
|
|
|
|
'csrf_token': self.csrf_token(),
|
|
|
|
|
'injury_id': self.existing_injury.id,
|
|
|
|
|
'diagnosis': 'Coach Attempted Update',
|
|
|
|
|
'external_notes': 'This update should fail',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# This should fail or redirect
|
|
|
|
|
update_response = self.url_open(
|
|
|
|
|
'/my/patient/injury/update',
|
|
|
|
|
data=update_data,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Refresh the record from database to check if changes were saved
|
|
|
|
|
self.existing_injury.invalidate_cache()
|
|
|
|
|
|
|
|
|
|
# Verify no changes were made
|
|
|
|
|
self.assertNotEqual(self.existing_injury.diagnosis, 'Coach Attempted Update')
|
|
|
|
|
|
|
|
|
|
def test_07_permission_escalation_prevention(self):
|
|
|
|
|
"""Test prevention of permission escalation through direct model access"""
|
|
|
|
|
# Login as coach to test permission boundaries
|
|
|
|
|
self.authenticate('security.coach@example.com', 'coach123')
|
|
|
|
|
|
|
|
|
|
# Try to directly call the server model methods that should be protected
|
|
|
|
|
# We'll use a JSON-RPC call to simulate attempting to escalate permissions
|
|
|
|
|
|
|
|
|
|
# Try to create a direct JSON-RPC call to update an injury
|
|
|
|
|
json_data = {
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"method": "call",
|
|
|
|
|
"params": {
|
|
|
|
|
"model": "sports.patient.injury",
|
|
|
|
|
"method": "write",
|
|
|
|
|
"args": [
|
|
|
|
|
self.existing_injury.id,
|
|
|
|
|
{"diagnosis": "Direct API Hack Attempt"}
|
|
|
|
|
],
|
|
|
|
|
"kwargs": {}
|
|
|
|
|
},
|
|
|
|
|
"id": 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# This should fail with an error code
|
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
|
response = self.url_open(
|
|
|
|
|
'/web/dataset/call_kw',
|
|
|
|
|
data=json.dumps(json_data),
|
|
|
|
|
headers=headers
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Parse JSON response and check for error
|
|
|
|
|
response_data = json.loads(response.text)
|
|
|
|
|
|
|
|
|
|
# Either access should be denied or the method should fail
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
'error' in response_data or
|
|
|
|
|
not response_data.get('result', False)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Verify the injury wasn't actually updated
|
|
|
|
|
self.existing_injury.invalidate_cache()
|
|
|
|
|
self.assertNotEqual(self.existing_injury.diagnosis, "Direct API Hack Attempt")
|
|
|
|
|
|
|
|
|
|
def test_08_csrf_protection(self):
|
|
|
|
|
"""Test CSRF protection for form submissions"""
|
|
|
|
|
# Login as therapist
|
|
|
|
|
self.authenticate('security.therapist@example.com', 'therapist123')
|
|
|
|
|
|
|
|
|
|
# Attempt a form submission without a valid CSRF token
|
|
|
|
|
invalid_data = {
|
|
|
|
|
'csrf_token': 'invalid_token',
|
|
|
|
|
'injury_id': self.existing_injury.id,
|
|
|
|
|
'diagnosis': 'CSRF Attack',
|
|
|
|
|
'external_notes': 'This should fail due to invalid CSRF token',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# This should fail with a 400 error or redirect to form
|
|
|
|
|
update_response = self.url_open(
|
|
|
|
|
'/my/patient/injury/update',
|
|
|
|
|
data=invalid_data,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check the injury record - it should not be updated
|
|
|
|
|
self.existing_injury.invalidate_cache()
|
|
|
|
|
self.assertNotEqual(self.existing_injury.diagnosis, 'CSRF Attack')
|