333 lines
20 KiB
Python
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)
|