"""Exercise the real PHP page in an isolated document root; never touch live data.""" import json import os from pathlib import Path import shutil import socket import subprocess import tempfile import threading import time import unittest import urllib.request import zipfile from unittest.mock import patch from playwright.sync_api import sync_playwright from test_pipeline import Gateway, _GatewayHandler, make_text_pdf WEB = Path('/var/www/html') INSERTION = Path('intranet_repo/projects/contentpanel/insertion') CENTRAL = Path('intranet_repo/projects/_central') class WebPreviewTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.temp = tempfile.TemporaryDirectory(prefix='contentpanel_web_test_') cls.addClassCleanup(cls.temp.cleanup) cls.root = Path(cls.temp.name) shutil.copytree(WEB / INSERTION, cls.root / INSERTION, ignore=shutil.ignore_patterns('news')) shutil.copytree(WEB / CENTRAL / 'layouts', cls.root / CENTRAL / 'layouts') shutil.copytree(WEB / CENTRAL / 'fonts', cls.root / CENTRAL / 'fonts') (cls.root / CENTRAL / 'scripts').mkdir(parents=True) shutil.copyfile(WEB / CENTRAL / 'scripts/contentpanel.js', cls.root / CENTRAL / 'scripts/contentpanel.js') bootstrap = cls.root / 'intranet_repo/general_files/initialise.php' bootstrap.parent.mkdir(parents=True) shutil.copyfile(Path(__file__).with_name('web_fixture.php'), bootstrap) (cls.root / CENTRAL / 'php').mkdir() for name in ('html_head', 'header', 'footer'): (cls.root / CENTRAL / f'php/{name}.php').write_text( '' if name == 'html_head' else '', encoding='utf-8') cls.news = cls.root / INSERTION / 'news/png' cls.news.mkdir(parents=True) cls.helper = cls.root / INSERTION / 'util/contentpanel_helpers.php' with zipfile.ZipFile(WEB / INSERTION / 'template/aushang-template.docx') as archive: cls.image = archive.read('word/media/image1.png') cls.publish('2030-12-31-Mehrseitig', 10) cls.publish('2030-12-31-Einzelseite', 1) (cls.news / '2030-12-31-Mehrseitig_page_invalid.png').write_bytes(cls.image) cls.pdf = cls.root / 'input.pdf' make_text_pdf(cls.pdf) with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) port = sock.getsockname()[1] cls.url = f'http://127.0.0.1:{port}/{INSERTION}/insert.php' cls.gateway = Gateway(response={'title': 'Gemeindefest am Sonntag', 'paragraphs': [ 'Beginn: 11:30 Uhr', 'Ort: Berlin-Reinickendorf', 'Herzliche Einladung nach dem Gottesdienst.']}) cls.gateway.__enter__() cls.addClassCleanup(cls.gateway.__exit__, None, None, None) cls.server = subprocess.Popen(['php', '-S', f'127.0.0.1:{port}', '-t', str(cls.root)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) cls.addClassCleanup(cls.stop_server) for _ in range(50): try: urllib.request.urlopen(cls.url, timeout=1).close() break except OSError: time.sleep(.1) else: raise RuntimeError('PHP test server did not start') cls.playwright = sync_playwright().start() cls.addClassCleanup(cls.playwright.stop) cls.browser = cls.playwright.chromium.launch(headless=True, args=['--disable-dev-shm-usage']) cls.addClassCleanup(cls.browser.close) @classmethod def stop_server(cls): cls.server.terminate() cls.server.wait(timeout=5) @classmethod def php(cls, code, *args, check=True): return subprocess.run(['php', '-r', 'require $argv[1]; ' + code, str(cls.helper), *args], check=check, capture_output=True, text=True, timeout=10) @classmethod def publish(cls, base, count): source = cls.root / 'generated' source.mkdir(exist_ok=True) files = [source / f'{base}_page_{number}.png' for number in range(1, count + 1)] for path in files: path.write_bytes(cls.image) cls.php('contentpanel_publish_news_pages(json_decode($argv[2], true), $argv[3]);', json.dumps([str(path) for path in files]), base) def setUp(self): self.page = self.browser.new_page(viewport={'width': 1280, 'height': 1000}) self.addCleanup(self.page.close) self.errors = [] self.page.on('pageerror', lambda error: self.errors.append(str(error))) self.page.goto(self.url + '#dateien') def tearDown(self): self.assertEqual(self.errors, []) def test_page_has_simple_preview_and_working_tabs_on_desktop_and_mobile(self): for width in (1280, 390): self.page.set_viewport_size({'width': width, 'height': 1000}) card = self.page.locator('.sb-aushang-card', has_text='Mehrseitig') self.assertEqual(card.locator('img').count(), 1) self.assertIn('_page_1.png?', card.locator('img').get_attribute('src')) self.assertIn('10 Seiten', card.inner_text()) self.assertIn('1 Seite', self.page.locator('.sb-aushang-card', has_text='Einzelseite').inner_text()) self.assertEqual(card.locator('a, details, [data-poster-navigation]').count(), 0) self.assertNotIn('Alle Seiten öffnen', self.page.content()) self.page.wait_for_function('[...document.querySelectorAll(".sb-aushang-card img")].every(i => i.complete && i.naturalWidth)') for tab in ('gottesdienst', 'ansagen', 'dateien'): self.page.locator(f'[data-tab="{tab}"]').click() self.assertTrue(self.page.locator(f'#tab-{tab}').is_visible()) self.assertTrue(self.page.evaluate('document.documentElement.scrollWidth <= innerWidth'), self.page.evaluate('[...document.querySelectorAll("body *")].filter(e => e.getBoundingClientRect().right > innerWidth).map(e => e.className)')) output = os.getenv('CONTENTPANEL_TEST_OUTPUT') if output: self.page.screenshot(path=str(Path(output) / f'web-{width}.png'), full_page=True, animations='disabled') self.assertEqual(self.page.locator('script:not([src])').count(), 0) def test_permissions_and_csrf_reject_mutations(self): denied = self.page.request.post(self.url + '?deny=1', form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig'}, max_redirects=0) self.assertEqual(denied.status, 303) self.assertIn('/intranet/index.php', denied.headers['location']) self.page.request.post(self.url, form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig', 'csrf_token': 'wrong'}, max_redirects=0) self.page.reload() self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text()) self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11))) def test_page_cleans_expired_announcements_on_each_authorized_request(self): log = self.root / 'cleanup.jsonl' before = len(log.read_text().splitlines()) self.page.reload() cleanup = [json.loads(line) for line in log.read_text().splitlines()] self.assertEqual(len(cleanup), before + 1) sql, params = cleanup[-1] self.assertEqual(sql, 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?') self.assertEqual(len(params), 1) self.assertRegex(params[0], r'^\d{4}-\d{2}-\d{2}$') self.page.request.get(self.url + '?deny=1', max_redirects=0) self.assertEqual(len(log.read_text().splitlines()), len(cleanup)) def test_forms_preserve_fields_validation_and_bound_sql_parameters(self): for tab, action, fields in ( ('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}), ('ansagen', 'create_announcement', {'ansage_titel': 'Gemeindefest', 'ansage_datum_von': '2030-12-31', 'ansage_ort': 'Saal'}), ): self.page.locator(f'[data-tab="{tab}"]').click() form = self.page.locator('form').filter(has=self.page.locator(f'input[name="action"][value="{action}"]')) for name, value in fields.items(): form.locator(f'[name="{name}"]').fill(value) form.locator('button[type="submit"]').click() self.page.wait_for_selector(f'#tab-{tab}.active .sb-alert') self.assertIn('erfolgreich angelegt', self.page.locator('.sb-alert').inner_text()) writes = [json.loads(line) for line in (self.root / 'writes.jsonl').read_text().splitlines()] self.assertEqual(writes[-2][1], ['Joh 3,16', 'Text & "Zitat"', '2030-12-31']) self.assertEqual(writes[-1][1], ['2030-12-31', None, None, 'Saal', 'Gemeindefest', '']) self.page.locator('#ansage_titel').fill('Ungültig') self.page.locator('#ansage_datum_von').fill('2030-12-31') self.page.locator('#ansage_datum_bis').fill('2030-01-01') self.page.locator('#tab-ansagen').get_by_role('button', name='Eintragen').click() self.page.wait_for_selector('.sb-alert-error') self.assertIn('Enddatum darf nicht', self.page.locator('.sb-alert-error').inner_text()) def test_original_upload_and_delete_through_real_page(self): self.page.locator('#news_date').fill('2030-12-31') self.page.locator('#news_name').fill('Uploadtest') self.page.locator('[name="format_mode"][value="original"]').check() self.page.locator('#fileToUpload').set_input_files(self.pdf) self.page.get_by_role('button', name='Datei hochladen').click() self.page.wait_for_selector('.sb-alert', timeout=60000) self.assertEqual('Aushang wurde veröffentlicht.', self.page.locator('.sb-alert').inner_text()) card = self.page.locator('.sb-aushang-card', has_text='Uploadtest') self.assertEqual(card.count(), 1) self.page.once('dialog', lambda dialog: dialog.dismiss()) card.get_by_role('button', name='Löschen').click() self.assertEqual(card.count(), 1) self.page.once('dialog', lambda dialog: dialog.accept()) card.get_by_role('button', name='Löschen').click() self.page.wait_for_selector('.sb-alert') self.assertEqual(card.count(), 0) self.assertFalse(list(self.news.glob('*Uploadtest*'))) def prepare_template_upload(self): self.page.locator('#news_date').fill('2030-12-31') self.page.locator('#news_name').fill('Fortschritt') self.page.locator('[name="format_mode"][value="template"]').check() self.page.locator('#fileToUpload').set_input_files(self.pdf) def test_template_upload_queues_immediately_and_allows_another_upload(self): calls = len(_GatewayHandler.calls) states = [] self.page.on('console', lambda message: states.append(json.loads(message.text[10:])) if message.text.startswith('upload-ui:') else None) for title in ('Erster-Auftrag', 'Zweiter-Auftrag'): self.prepare_template_upload() self.page.locator('#news_name').fill(title) self.page.evaluate('''() => { const field = document.createElement('input'); field.name = 'mail'; field.value = 'forged@example.test'; field.type = 'hidden'; document.querySelector('#tab-dateien [data-upload-form]').append(field); }''') self.page.evaluate('''() => document.querySelector('#tab-dateien [data-upload-form]').addEventListener('submit', () => { const form = document.querySelector('#tab-dateien [data-upload-form]'); console.log('upload-ui:' + JSON.stringify([ getComputedStyle(form.querySelector('.sb-spinner')).display, form.querySelector('[type=submit]').disabled])); })''') self.page.get_by_role('button', name='Datei hochladen').click() self.page.wait_for_selector('.sb-alert') self.assertEqual(states[-1], ['block', True]) message = self.page.locator('.sb-alert').inner_text() self.assertIn('Hintergrund', message) self.assertIn('E-Mail', message) self.assertFalse(list(self.news.glob('*' + title + '*'))) self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled()) self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0) writes = [json.loads(line) for line in (self.root / 'writes.jsonl').read_text().splitlines()] sql, parameters = writes[-1] self.assertIn('INSERT INTO contentpanel_jobs', sql) self.assertEqual(json.loads(parameters[0])['mail'], 'member@example.test') import base64 self.assertEqual(base64.b64decode(parameters[1]), self.pdf.read_bytes()) self.assertEqual(len(_GatewayHandler.calls), calls) def test_invalid_upload_is_not_queued_and_can_be_retried(self): self.prepare_template_upload() self.page.locator('#fileToUpload').set_input_files({'name': 'broken.pdf', 'mimeType': 'application/pdf', 'buffer': b'no pdf'}) self.page.get_by_role('button', name='Datei hochladen').click() self.page.wait_for_selector('.sb-alert-error') self.assertIn('gültige PDF', self.page.locator('.sb-alert-error').inner_text()) self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled()) def test_queued_upload_still_requires_permission_and_csrf(self): form = {'action': 'upload_news', 'format_mode': 'template', 'csrf_token': 'wrong'} denied = self.page.request.post(self.url + '?deny=1', form=form, max_redirects=0) self.assertEqual(denied.status, 303) response = self.page.request.post(self.url, form=form, max_redirects=0) self.assertEqual(response.status, 303) self.page.reload() self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text()) def test_republishing_fewer_pages_and_failed_publish_preserve_existing_files(self): base = '2030-12-31-Austausch' self.publish(base, 10) self.publish(base, 2) result = self.php('echo json_encode(contentpanel_list_news());') item = next(item for item in json.loads(result.stdout) if item['base'] == base) self.assertEqual(item['pages'], 2) self.assertEqual(len(list(self.news.glob(base + '_page_*.png'))), 2) existing = {p.name: p.read_bytes() for p in self.news.glob(base + '_page_*.png')} failed = self.php('contentpanel_publish_news_pages([$argv[2]], $argv[3]);', '/missing/page.png', base, check=False) self.assertNotEqual(failed.returncode, 0) self.assertEqual(existing, {p.name: p.read_bytes() for p in self.news.glob(base + '_page_*.png')}) self.assertFalse(list(self.news.glob('*.tmp-*'))) self.assertEqual(self.php('echo json_encode(contentpanel_delete_news_files("../../other"));').stdout, 'false') def test_publication_rolls_back_after_partial_rename(self): base = '2030-12-31-Rollback' self.publish(base, 2) original = {p.name: p.read_bytes() for p in self.news.glob(base + '_page_*.png')} source = self.root / 'rollback-input' source.mkdir() files = [source / f'{base}_page_{n}.png' for n in (1, 2)] for path in files: path.write_bytes(b'new image') # Fail exactly the second publish rename, after the first page was replaced. # Namespaced function injection leaves application code and real files untouched. injected = self.root / INSERTION / 'util/failing-news.php' prefix = r""" actual Python worker -> gateway -> PHP error mapping. out = self.root / 'mismatched-content' with Gateway(response={"title": "Gemeindefest 2099", "paragraphs": ["Der Aushang beschreibt ein Fest."]}): code = """try { contentpanel_render_news($argv[2], $argv[3], '2030-12-31-Test', 'template'); } catch (InvalidArgumentException $e) { echo $e->getMessage(); }""" result = self.php(code, str(self.pdf), str(out)) self.assertIn('Die KI-Ausgabe enthält eine nicht belegte Zahl oder Kontaktangabe', result.stdout) self.assertNotIn('/opt/', result.stdout) self.assertIn('LLM-Ausgabe enthält eine nicht belegte Zahl oder Kontaktangabe', result.stderr) self.assertFalse(list(out.glob('*.png'))) def test_leitgedanken_parser_handles_hyphenation_and_duplicates(self): text = 'Sonntag 13.09.2030\n\nDie Ge-\nmeinde freut sich. Joh 3,16\n\nSonntag 13.09.2030\n\nDuplikat bleibt unberücksichtigt. Joh 3,16' result = self.php('echo json_encode(contentpanel_parse_leitgedanken($argv[2]));', text) self.assertEqual(json.loads(result.stdout), [{'bibeldatum': '2030-09-13', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Die Gemeinde freut sich.'}]) text = 'Sonntag 13./14.09.2030\nErster Vers. Joh 3,16\nWeiterer Vers. Ps 1,1\nSonntag 20.09.2030\n\nZweiter Vers. 1. Kor 13,1-3' result = self.php('echo json_encode(contentpanel_parse_leitgedanken($argv[2]));', text) self.assertEqual([item['bibelwort'] for item in json.loads(result.stdout)], ['Joh 3,16', '1. Kor 13,1-3']) if __name__ == '__main__': unittest.main(verbosity=2)