Files
intranet/worker/contentpanel_aushang/tests/test_web_preview.py
T

333 lines
20 KiB
Python

"""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(
'<meta charset="utf-8"><link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/nak-design.css">'
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_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('#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])
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text())
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text())
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text())
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"""<?php namespace Fault; use \RuntimeException; use \Throwable;
function rename($from, $to) {
if (str_contains($from, '/rollback-input/') && str_ends_with($to, '_page_2.png')) return false;
return \rename($from, $to);
}
"""
injected.write_text(prefix + (self.helper.parent / 'news.php').read_text().removeprefix('<?php'), encoding='utf-8')
result = self.php('require $argv[2]; Fault\\contentpanel_publish_news_pages(json_decode($argv[3], true), $argv[4]);',
str(injected), json.dumps([str(path) for path in files]), base, check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Aushangseite konnte nicht veröffentlicht', result.stderr)
self.assertEqual(original, {p.name: p.read_bytes() for p in self.news.glob(base + '_page_*.png')})
self.assertFalse(list(self.news.glob(base + '*.bak-*')))
def test_delete_waits_for_publication_lock(self):
import fcntl
base = '2030-12-31-Sperre'
self.publish(base, 1)
with (self.news / '.publish.lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
process = subprocess.Popen(['php', '-r',
'require $argv[1]; echo "ready\\n"; contentpanel_delete_news_files($argv[2]);', str(self.helper), base],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
self.assertEqual(process.stdout.readline().strip(), 'ready')
self.assertIsNone(process.poll())
self.assertTrue((self.news / f'{base}_page_1.png').is_file())
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
process.communicate(timeout=5)
self.assertEqual(process.returncode, 0)
self.assertFalse((self.news / f'{base}_page_1.png').exists())
def test_delete_actions_bind_ids_and_reject_invalid_values(self):
for tab, action, table in (('gottesdienst', 'delete_service', 'gottesdienst'), ('ansagen', 'delete_announcement', 'ansagen')):
self.page.locator(f'[data-tab="{tab}"]').click()
form = self.page.locator('form').filter(has=self.page.locator(f'[name="action"][value="{action}"]'))
self.page.once('dialog', lambda dialog: dialog.accept())
form.get_by_role('button', name='Löschen').click()
self.page.wait_for_selector(f'#tab-{tab} .sb-alert')
sql, params = json.loads((self.root / 'writes.jsonl').read_text().splitlines()[-1])
self.assertEqual(sql, f'DELETE FROM {table} WHERE id = ?')
self.assertEqual(len(params), 1)
self.assertIsInstance(params[0], int)
failed = self.php('contentpanel_positive_id("1 OR 1=1");', check=False)
self.assertNotEqual(failed.returncode, 0)
def test_worker_content_error_becomes_actionable_message(self):
# Exercise PHP -> 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)