diff --git a/apps/output/tests.py b/apps/output/tests.py index f87c8340..b1c7d589 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -1,5 +1,8 @@ from django.test import TestCase, Client from django.urls import reverse +from apps.channels.models import Channel, ChannelGroup +from apps.epg.models import EPGData, EPGSource +import xml.etree.ElementTree as ET class OutputM3UTest(TestCase): def setUp(self): @@ -37,3 +40,106 @@ class OutputM3UTest(TestCase): self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) + + +class OutputEPGXMLEscapingTest(TestCase): + """Test XML escaping of channel_id attributes in EPG generation""" + + def setUp(self): + self.client = Client() + self.group = ChannelGroup.objects.create(name="Test Group") + + def test_channel_id_with_ampersand(self): + """Test channel ID with ampersand is properly escaped""" + channel = Channel.objects.create( + channel_number=1.0, + name="Test Channel", + tvg_id="News & Sports", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # Should contain escaped ampersand + self.assertIn('id="News & Sports"', content) + self.assertNotIn('id="News & Sports"', content) + + # Verify XML is parseable + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG is not valid XML: {e}") + + def test_channel_id_with_angle_brackets(self): + """Test channel ID with < and > characters""" + channel = Channel.objects.create( + channel_number=2.0, + name="HD Channel", + tvg_id="Channel ", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Channel <HD>"', content) + + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG with < > is not valid XML: {e}") + + def test_channel_id_with_all_special_chars(self): + """Test channel ID with all XML special characters""" + channel = Channel.objects.create( + channel_number=3.0, + name="Complex Channel", + tvg_id='Test & "Special" ', + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Test & "Special" <Chars>"', content) + + try: + tree = ET.fromstring(content) + # Verify we can find the channel with correct ID in parsed tree + channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" "]') + self.assertIsNotNone(channel_elem) + except ET.ParseError as e: + self.fail(f"Generated EPG with all special chars is not valid XML: {e}") + + def test_program_channel_attribute_escaping(self): + """Test that programme elements also have escaped channel attributes""" + epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy") + epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source) + channel = Channel.objects.create( + channel_number=4.0, + name="Program Test", + tvg_id="News & Sports", + epg_data=epg_data, + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + + # Check programme elements have escaped channel attributes + self.assertIn('channel="News & Sports"', content) + + try: + tree = ET.fromstring(content) + programmes = tree.findall('.//programme[@channel="News & Sports"]') + self.assertGreater(len(programmes), 0) + except ET.ParseError as e: + self.fail(f"Generated EPG with programme elements is not valid XML: {e}") diff --git a/apps/output/views.py b/apps/output/views.py index ba75246a..58707ae2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1203,7 +1203,7 @@ def generate_dummy_epg( # Create program entry with escaped channel name xml_lines.append( - f' ' + f' ' ) xml_lines.append(f" {html.escape(program['title'])}") xml_lines.append(f" {html.escape(program['description'])}") @@ -1448,7 +1448,7 @@ def generate_epg(request, profile_name=None, user=None): else: tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) display_name = channel.name - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') xml_lines.append(" ") @@ -1523,7 +1523,7 @@ def generate_epg(request, profile_name=None, user=None): stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") # Create program entry with escaped channel name - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" @@ -1572,7 +1572,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" @@ -1634,7 +1634,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - program_xml = [f' '] + program_xml = [f' '] program_xml.append(f' {html.escape(prog.title)}') # Add subtitle if available