Einwilligung, die tatsächlich zurückhält, und Pflichtseiten
Ein Hinweis, der nur fragt und sonst nichts ändert, ist Theater. Deshalb zwei Hälften, und die zweite ist die wichtigere. Die Frage: serverseitig gerendert, per Formular beantwortet, ohne JavaScript - die öffentlichen Seiten laufen mit script-src 'none', und ein Banner mit Skript wäre das eine, was diese Regel bricht. "Alle akzeptieren" und "Nur notwendige" sind dieselbe Art Knopf, gleich groß, nebeneinander. Die Entscheidung steht in einem eigenen Cookie: Fassung der Frage, gewährte Gruppen, Zeitpunkt. Keine Kennung, kein Serverabgleich, ein halbes Jahr. Kein CSRF-Token, absichtlich: die Anfrage ändert nichts auf dem Server, und ein Token bräuchte für jeden Besucher eine Sitzung - genau das, was ein Einwilligungshinweis vermeiden soll. Geprüft wird stattdessen das Rücksprungziel. Das Zurückhalten: der neue Block "Externer Inhalt" (Dokumentschema v14) lädt ohne Einwilligung nichts - kein iframe, keine Adresse im Markup, frame-src 'none'. Der Platzhalter nennt den Anbieter; sein Knopf gewährt genau diese Gruppe und nimmt die vorher gewährten mit. Erst danach kommt der Host in die Sicherheitsrichtlinie, und nur dieser. Im Editor bleibt es immer ein Platzhalter. Datenschutz und Impressum sind keine Einstellung mehr: fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst an, mit NOCH AUSZUFÜLLEN an jeder Pflichtangabe. Den Text schreibt das CMS nicht - das ist eine rechtliche Aussage über eine Firma. Verlinkt werden beide aus dem Template, nicht aus dem Fußbereich: ein Pflichtlink, den man löschen kann, ist keiner. Dazu der Fehler, den Umut im Screenshot gesehen hat: die Wortmarke stand als Buchstabenkolonne in einem 60 Pixel breiten Kasten. .site-header war noch ein Flexcontainer aus der Zeit des handgeschriebenen Markups und quetschte die Sektion zusammen, und design.css band jede Designklasse an #main - wo Kopf- und Fußbereich seit ADR 026 nicht mehr liegen. Beides behoben. Neu im Ausbauplan: Statistik (serverseitige, wiedererkennungsfreie Zählung, die deshalb keine Einwilligung braucht), Inhalte und Ladeverhalten prüfen (Blättern überall, minimal laden, Ladebudget), Installer (Schritt für Schritt im Browser, Migration inklusive). 245 PHP-Tests / 976 Assertions unter PHP 8.2.12 und 8.4.22. Der Browsertest fand zwei Fehler von mir: grauer Text auf dunklem Fußbereich mit 2,24:1, und das fixierte Banner verdeckte den Einwilligungsknopf am Seitenende. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84d471acd9
commit
ee446f1ea1
30 files changed
+1298
-154
No files matched your search
@@ -1,7 +1,12 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* The design classes are scoped to the body class, not to #main: the header and the footer are
|
||||
* block documents of their own and live outside #main, so a #main prefix left every design token
|
||||
* in them without effect - a row in the header ignored its column count.
|
||||
*/
|
||||
export function designCss(tokens) {
|
||||
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `#main .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `.kant .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`;
|
||||
}
|
||||
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) {
|
||||
|
||||
@@ -372,6 +372,63 @@ try {
|
||||
await page.screenshot({ path: 'reports/editor/media-screen.png', fullPage: true });
|
||||
checks.push({ name: 'management screens reachable, media library rendered as a page', passed: true });
|
||||
|
||||
// The consent question, switched on the way an administrator would switch it on, and then
|
||||
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
|
||||
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
// A page with an embed, added through the API so the check stays about consent, not the editor.
|
||||
const forEmbed = await (await context.request.get(origin + '/admin/api/pages/test-projektstand')).json();
|
||||
const withEmbed = structuredClone(forEmbed.document);
|
||||
withEmbed.schemaVersion = 14;
|
||||
withEmbed.blocks.push({
|
||||
id: 'karte', type: 'embed',
|
||||
props: { group: 'external', title: 'Anfahrt', provider: 'OpenStreetMap', url: 'https://karten.example/einbetten', hint: '', height: 320 },
|
||||
});
|
||||
const embedSave = await context.request.post(origin + '/admin/api/pages/test-projektstand/draft', { headers: { 'X-CSRF-Token': forEmbed.csrfToken }, data: { document: withEmbed, expectedVersion: forEmbed.version } });
|
||||
assert.equal(embedSave.status(), 200, await embedSave.text());
|
||||
// The version and the revision after a save are whatever the server says, not an assumption.
|
||||
const afterSave = await (await context.request.get(origin + '/admin/api/pages/test-projektstand')).json();
|
||||
const published = await context.request.post(origin + '/admin/api/pages/test-projektstand/publish', { headers: { 'X-CSRF-Token': afterSave.csrfToken }, data: { revisionId: afterSave.revisionId, expectedVersion: afterSave.version } });
|
||||
assert.equal(published.status(), 200, await published.text());
|
||||
|
||||
// A separate context: a visitor without the administration's cookies.
|
||||
const visitor = await browser.newContext({ viewport: { width: 1100, height: 900 } });
|
||||
const visitorPage = await visitor.newPage();
|
||||
await visitorPage.goto(origin + '/projektstand');
|
||||
await visitorPage.locator('.consent-bar').waitFor();
|
||||
assert.equal(await visitorPage.locator('iframe').count(), 0, 'Nothing may load before consent.');
|
||||
assert.equal((await visitorPage.content()).includes('karten.example/einbetten'), false, 'The address must not even be in the markup.');
|
||||
const blocked = await new AxeBuilder({ page: visitorPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||
assert.deepEqual(blocked.violations.map(v => v.id), [], 'consent banner accessibility');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-banner.png', fullPage: false });
|
||||
|
||||
// Consenting through the embed's own button grants exactly that group, and then it loads.
|
||||
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
||||
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
|
||||
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
|
||||
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
|
||||
// And the way back: the consent page can refuse again, which blocks the embed once more.
|
||||
await visitorPage.goto(origin + '/einwilligung');
|
||||
await new AxeBuilder({ page: visitorPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()
|
||||
.then(result => assert.deepEqual(result.violations.map(v => v.id), [], 'consent page accessibility'));
|
||||
await visitorPage.getByRole('button', { name: 'Nur notwendige', exact: true }).click();
|
||||
await visitorPage.goto(origin + '/projektstand');
|
||||
assert.equal(await visitorPage.locator('iframe').count(), 0, 'A refusal must stick.');
|
||||
await visitor.close();
|
||||
checks.push({ name: 'consent banner, blocked embed, granting per embed and revoking', passed: true });
|
||||
console.log('Consent checks passed.');
|
||||
|
||||
// Back to the way the rest of the run expects it: no question, so the other screens stay as they were.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).uncheck();
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
// Back into the editor. The draft differs from the published state by now, so wait for the
|
||||
// structure rather than for a particular save status.
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
|
||||
Reference in new issue
Block a user