Terminplaner: Gaesteseiten – Nachbesserungen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 734e397e73
commit 70940e02c8
5 files changed
+193 -33

No files matched your search

+23 -3
View File
@@ -65,13 +65,32 @@ test('Pflichtbausteine der Aufgabe (Ansichten, Knopftexte, Fuss, Farbe)', () =>
for (const noetig of [
'Widerruf bestätigen', 'Vertrag widerrufen', 'Termin absagen', 'Termin verschieben', 'Ja, Termin absagen', 'Nein, Termin behalten',
'bereits bestätigt', 'Eingang: ', 'eingang.datum', 'eingang.uhrzeit', 'websiteUrl', 'telefon', 'impressumUrl', 'datenschutzUrl',
'noopener noreferrer', 'httpsUrl', 'v.knopf', 'd.anrede', 'd.farbe', '§ 356a BGB', 'role',
'noopener noreferrer', 'httpsUrl', 'v.knopf', 'bereits abgesendet', 'd.anrede', 'd.farbe', '§ 356a BGB', 'role',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt in termin.js`)
}
assert.ok(!/\bconfirm\(|\balert\(|\bprompt\(/.test(OHNE_KOMMENTARE), 'keine Browser-Dialoge: die Rueckfrage steht in der Seite')
})
test('Token: replaceState steht in try/catch (wirft der Browser, laeuft die Seite weiter) und vor dem ersten Aufruf', () => {
assert.match(OHNE_KOMMENTARE, /try\s*\{\s*window\.history\.replaceState\([^)]*\)\s*\}\s*catch/)
assert.ok(OHNE_KOMMENTARE.indexOf('history.replaceState') < OHNE_KOMMENTARE.indexOf('lade()', OHNE_KOMMENTARE.indexOf('function start')), 'replaceState vor lade()')
})
test('Widerruf ist ein gleichrangiger Knopf (§ 356a BGB), kein Textlink; kein totes absaetze()', () => {
assert.ok(OHNE_KOMMENTARE.includes("knopf('t-btn t-btn-sek', 'Vertrag widerrufen'"), 'Widerruf als sekundaerer Knopf')
assert.doesNotMatch(OHNE_KOMMENTARE, /['"\s]t-link['"\s]/, 'kein Textlink-Knopf')
assert.doesNotMatch(CSS, /\.t-link\b(?!s)/, 'kein Textlink-Knopf in termin.css')
assert.ok(!OHNE_KOMMENTARE.includes('absaetze'), 'totes absaetze() entfernt')
})
test('termin.css: Reihenfolge im DOM = Anzeige (kein *-reverse), Ueberschrift ohne Rahmen, Bedienelemente mit Rahmen', () => {
assert.doesNotMatch(CSS, /-reverse/, 'kein row-reverse/column-reverse: Tab-Reihenfolge muss der Anzeige entsprechen')
// die Regel fuer die Ueberschrift muss spezifischer sein als `.t-karte :focus-visible`
assert.match(CSS, /\.t-karte \.t-titel:focus-visible\s*\{[^}]*outline:\s*none/)
assert.match(CSS, /\.t-karte :focus-visible[^{]*\{[^}]*outline:\s*3px solid/)
})
test('termin.html: noindex, kein Referrer, Stile und Skript von der eigenen Herkunft, Fuss mit Impressum WEBklar', () => {
assert.match(HTML, /<meta name="robots" content="[^"]*noindex/, 'noindex fehlt')
assert.match(HTML, /<meta name="referrer" content="no-referrer"/)
@@ -80,8 +99,9 @@ test('termin.html: noindex, kein Referrer, Stile und Skript von der eigenen Herk
assert.match(HTML, /aria-live="polite"/)
assert.match(HTML, /<link rel="stylesheet" href="\/termin\.css">/)
assert.match(HTML, /<script src="\/termin\.js" defer><\/script>/)
assert.match(HTML, /<a href="\/impressum\.html"[^>]*>Impressum WEBklar<\/a>/)
assert.doesNotMatch(HTML, /https?:\/\//, 'keine Fremdadressen in termin.html')
// Ruling R46: Impressum WEBklar wie auf den anderen Portalseiten (das Portal hat kein /impressum.html)
assert.match(HTML, /<a href="https:\/\/webklar\.com\/impressum" target="_blank" rel="noopener noreferrer">Impressum WEBklar<\/a>/)
assert.deepEqual(HTML.match(/https?:\/\/[^\s"'<>]+/g), ['https://webklar.com/impressum'], 'genau diese eine Fremdadresse in termin.html')
assert.doesNotMatch(HTML, /<script(?![^>]*\bsrc=)[^>]*>/, 'kein Inline-Skript')
assert.doesNotMatch(HTML, /\sstyle=/, 'keine Inline-Stile')
})
+137 -2
View File
@@ -101,17 +101,36 @@ async function knoepfeGross(page) {
return kleine
}
/**
* Tab-Reihenfolge = Anzeige: die bedienbaren Elemente der Karte stehen in DOM-Reihenfolge auch auf dem Bildschirm in Leserichtung
* (Zeile fuer Zeile, links nach rechts). Liefert die Paare, bei denen das nicht stimmt.
*/
async function reihenfolgeFehler(page) {
const k = await page.evaluate(() => [...document.querySelectorAll('#karte button, #karte a[href], #karte input, #karte textarea')]
.filter((e) => e.offsetParent !== null && !e.disabled && e.tabIndex >= 0)
.map((e) => { const r = e.getBoundingClientRect(); return { t: (e.textContent || e.id || '').trim().slice(0, 30), x: Math.round(r.left), y: Math.round(r.top) } }))
const fehler = []
for (let i = 1; i < k.length; i += 1) {
const [a, b] = [k[i - 1], k[i]]
const spaeterZeile = b.y > a.y + 4
const gleicheZeile = Math.abs(b.y - a.y) <= 4 && b.x > a.x
if (!spaeterZeile && !gleicheZeile) fehler.push(`${a.t} -> ${b.t}`)
}
return fehler
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, context = {} } = {}) {
async function check(name, fn, { eingabe, context = {}, initScript } = {}) {
await srv.zuruecksetzen({ eingabe })
const ctx = await browser.newContext({ viewport: { width: 1000, height: 900 }, ...context })
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
if (initScript) await page.addInitScript(initScript)
const fehler = []
page.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
@@ -159,6 +178,11 @@ async function pruefungen() {
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 1, 'Knopf des Servers')
eq(await page.evaluate(() => document.activeElement.tagName), 'H1', 'Fokus auf der Ueberschrift')
await shot(page, 'bestaetigen')
// Ueberschrift ohne Fokusrahmen, Bedienelemente (Tab) mit sichtbarem Rahmen
eq(await page.evaluate(() => getComputedStyle(document.activeElement).outlineStyle), 'none', 'Ueberschrift ohne Fokusrahmen')
await page.keyboard.press('Tab')
eq(await page.evaluate(() => ({ tag: document.activeElement.tagName, stil: getComputedStyle(document.activeElement).outlineStyle, breite: getComputedStyle(document.activeElement).outlineWidth })),
{ tag: 'BUTTON', stil: 'solid', breite: '3px' }, 'Knopf mit sichtbarem Fokusrahmen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
@@ -208,6 +232,7 @@ async function pruefungen() {
srv.schiebeUhr(31 * 60 * 1000)
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(await page.locator('#kopf').evaluate((e) => getComputedStyle(e).backgroundColor), 'rgb(15, 118, 110)', 'mitten in der Sitzung bleibt die Betriebsfarbe im Kopf')
eq(buchungVon('max@example.com').status === 'bestaetigt', false, 'abgelaufene Haltung wird nicht bestaetigt')
// frischer Aufruf: dieselbe Ansicht mit Link zur Website und Telefon des Betriebs
const neu = await ctx.newPage()
@@ -256,6 +281,13 @@ async function pruefungen() {
eq(mailsAn('f@example.com').length, 2, 'G01 und G03')
eq(mailsAn(BETRIEB_MAIL).length, 1, 'K20 (Anfrage) an den Betrieb')
await shot(page, 'anfrage-eingegangen')
// zweiter Aufruf des Bestaetigungslinks: die Anfrage ist schon abgesendet (nicht "bereits bestaetigt")
await oeffne(page, 'v', f.vToken)
await warteH1(page, 'Ihre Anfrage wurde bereits abgesendet.')
const zweit = await text(page)
assert(zweit.includes('Sie erhalten eine E-Mail, sobald Salon Müller sie annimmt.') && !zweit.includes('bereits bestätigt'), `zweiter Aufruf der Anfrage: ${zweit}`)
eq(await page.getByRole('button').count(), 0, 'kein Knopf beim zweiten Aufruf')
await shot(page, 'anfrage-bereits-abgesendet')
// Verwaltungslink der Anfrage: angefragt, Absagen moeglich, kein Widerruf (noch kein Vertrag)
const m = tokenAusMail('f@example.com', 'm')
await oeffne(page, 'm', m)
@@ -296,11 +328,17 @@ async function pruefungen() {
assert(t.includes('Bestätigt') && t.includes('Montag, 12.10.2026') && t.includes('10:00 Uhr'), `Verwaltungsansicht: ${t}`)
for (const name of ['Termin verschieben', 'Termin absagen', 'Vertrag widerrufen']) eq(await knopf(page, name).count(), 1, `Knopf ${name}`)
eq(await page.getByRole('button', { name: 'Zahlungspflichtig' }).count(), 0, 'kein Bestaetigungsknopf')
// § 356a BGB: der Widerruf ist ein gleichrangiger Knopf in derselben Reihe, kein Textlink
eq(await page.locator('.t-nav button').allTextContents(), ['Termin verschieben', 'Termin absagen', 'Vertrag widerrufen'], 'drei Knoepfe in einer Reihe')
assert((await knopf(page, 'Vertrag widerrufen').getAttribute('class')).includes('t-btn-sek'), 'Widerruf als sekundaerer Knopf')
assert((await knopf(page, 'Vertrag widerrufen').boundingBox()).height >= 43.5, 'Widerruf-Knopf mindestens 44 px hoch')
eq(await reihenfolgeFehler(page), [], 'Tab-Reihenfolge der Verwaltungsansicht')
await shot(page, 'verwalten')
// Rueckfrage in der Seite (kein Browser-Dialog: check() meldet jeden Dialog als Fehler); "Nein" laesst alles wie es war
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
eq(await reihenfolgeFehler(page), [], 'Tab-Reihenfolge der Rueckfrage')
await shot(page, 'absagen-frage')
await knopf(page, 'Nein, Termin behalten').click()
await warteH1(page, 'Ihr Termin')
@@ -427,6 +465,7 @@ async function pruefungen() {
eq([await page.locator('#t-f-email').getAttribute('readonly') !== null, await page.locator('#t-f-nummer').getAttribute('readonly') !== null], [true, true], 'E-Mail und Nummer schreibgeschuetzt')
assert((await text(page)).includes(`Buchungsnummer ${nummer}).`) && (await text(page)).includes('Hiermit widerrufe ich'), 'Erklaerung mit Buchungsnummer')
eq(await knopf(page, 'Widerruf bestätigen').count(), 1, 'Knopf "Widerruf bestätigen"')
eq(await reihenfolgeFehler(page), [], 'Tab-Reihenfolge des Widerrufsformulars')
await shot(page, 'widerruf-formular')
// ohne Namen geht nichts raus
await page.locator('#t-f-name').fill(' ')
@@ -448,6 +487,92 @@ async function pruefungen() {
await shot(page, 'widerruf-eingang')
}, { context: { timezoneId: 'America/New_York', locale: 'en-US' } })
// ------------------------------------------------------------ 9b. Token vor dem ersten Aufruf weg; replaceState wirft
await check('token_vor_dem_ersten_aufruf_entfernt', async (page) => {
const b = await buche()
const adressen = []
// Das Skript ruft den Server erst nach replaceState: beim ersten Aufruf steht in der Adresszeile kein Token mehr
await page.route('**/termin/vorschau', async (route) => {
adressen.push(await page.evaluate(() => location.href))
await route.continue()
})
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
eq(adressen, [SEITE], 'Adresse beim ersten Aufruf ohne #v= und #m=')
assert(!adressen.some((a) => /#[vm]=/.test(a)), 'Token in der Adresse beim Aufruf')
})
await check('replacestate_wirft_seite_laeuft_weiter', async (page) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen') // nicht "Wird geladen …" stehen geblieben
assert(!(await text(page)).includes('Wird geladen'), '"Wird geladen" darf nicht stehen bleiben')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
}, { initScript: () => { history.replaceState = () => { throw new Error('gesperrt') } } })
// ------------------------------------------------------------ 9c. Abgelaufen mitten in der Sitzung: Farbe und Anrede bleiben
await check('abgelaufen_mitten_in_der_sitzung_du', async (page) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
assert((await text(page)).includes('Bitte prüfe die Angaben'), 'Du-Form vor dem Ablauf')
srv.schiebeUhr(31 * 60 * 1000)
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(await page.locator('#kopf').evaluate((e) => ({ bg: getComputedStyle(e).backgroundColor, fg: getComputedStyle(e).color })), { bg: 'rgb(124, 58, 237)', fg: 'rgb(255, 255, 255)' }, 'Betriebsfarbe bleibt im Kopf')
assert((await text(page)).includes('Bitte buche deinen Termin noch einmal oder ruf uns an.'), `Du-Form bleibt: ${await text(page)}`)
eq(await page.locator('#fuss a').evaluateAll((as) => as.map((a) => a.textContent)), ['Impressum', 'Datenschutz', 'Impressum WEBklar'], 'Fusslinks des Betriebs bleiben')
await shot(page, 'abgelaufen-mitten-in-der-sitzung')
}, { eingabe: salonEingabe({ anrede: 'du', farbe: '#7c3aed' }) })
// ------------------------------------------------------------ 9d. Feindliche Betriebsangaben (nichts wird Link oder Markup)
// Name mit Markup, Telefon mit javascript: und Adresse mit Markup gehen durch die Pruefung der Einstellung (freie Texte) und kommen
// ueber speichere() an. Die Adressen (Impressum, Datenschutz, Website) lehnt die Pruefung ab (nur https): sie werden danach direkt im
// Dokument der Einstellung im Nachbau von Appwrite ersetzt (Altdaten / umgangene Pruefung), damit die Seite selbst geprueft wird.
const FEIND_NAME = '<img src=x onerror=window.__xss=1>Salon <b>&amp;</b>'
await check('feindliche_betriebsangaben', async (page, ctx) => {
const b = await buche()
// Betriebsangaben im gespeicherten Dokument ersetzen (aw.seed ueberschreibt das Dokument), Zwischenspeicher des Servers leeren
const setzeBetrieb = (felder) => {
const doc = srv.aw.docs('termineConfig').get(srv.FEATURE)
const e = JSON.parse(doc.einstellung)
Object.assign(e.betrieb, felder)
srv.aw.seed('termineConfig', { ...doc, einstellung: JSON.stringify(e) })
srv.limitsZuruecksetzen()
}
setzeBetrieb({ impressumUrl: 'javascript:alert(1)', datenschutzUrl: 'http://evil.example/datenschutz', websiteUrl: 'http://evil.example/' })
const vorschau = page.waitForResponse((r) => r.url().endsWith('/termin/vorschau'))
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const antwort = (await (await vorschau).json()).betrieb
eq([antwort.impressumUrl, antwort.datenschutzUrl, antwort.name, antwort.telefon], ['javascript:alert(1)', 'http://evil.example/datenschutz', FEIND_NAME, 'javascript:alert(1)'], 'die feindlichen Werte erreichen die Seite')
eq(await page.locator('#betrieb').innerText(), FEIND_NAME, 'Name als Text, nicht als Markup')
eq(await page.locator('#kopf img, #karte img, #kopf b, #karte b').count(), 0, 'kein Element aus dem Namen oder der Adresse')
assert((await text(page)).includes('<b>Hauptstr. 1</b>'), 'Adresse als Text')
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt')
eq(await page.locator('#fuss a').evaluateAll((as) => as.map((a) => a.getAttribute('href'))), ['https://webklar.com/impressum'], 'weder javascript: noch http: im Fuss')
assert((await page.title()).includes(FEIND_NAME), 'Seitentitel als Text')
// abgelaufen: Website (http) ist kein Link, Telefon mit javascript: ist Text (kein Link), nichts wird ausgefuehrt
srv.schiebeUhr(31 * 60 * 1000)
srv.limitsZuruecksetzen()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(await page.locator('#betrieb').innerText(), FEIND_NAME, 'Name im abgelaufenen Zustand als Text')
eq(await page.locator('#karte a').count(), 0, 'kein Link (Website http, Telefon javascript:)')
assert((await text(page)).includes('Telefon: javascript:alert(1)'), 'Telefon als Text')
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt (abgelaufen)')
await shot(page, 'feindliche-angaben')
// eine ordentliche Nummer wird ein tel:-Link, nur mit Ziffern und +
setzeBetrieb({ telefon: '+49 (0) 6301-1234' })
await oeffne(page, 'v', b.vToken)
await page.getByRole('link', { name: '+49 (0) 6301-1234' }).waitFor()
eq(await page.getByRole('link', { name: '+49 (0) 6301-1234' }).getAttribute('href'), 'tel:+49063011234', 'bereinigter tel:-Link')
}, { eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, name: FEIND_NAME, telefon: 'javascript:alert(1)', adresse: '<b>Hauptstr. 1</b>' } }) })
// ------------------------------------------------------------ 10. Du-Form
await check('du_anrede', async (page) => {
const b = await buche()
@@ -464,6 +589,15 @@ async function pruefungen() {
await warteH1(page, 'Vertrag widerrufen')
assert((await text(page)).includes('Hier kannst du den Vertrag über diesen Termin widerrufen'), 'Du-Form im Widerruf')
await shot(page, 'du-widerruf')
// Anfrage, zweiter Aufruf des Bestaetigungslinks (Du-Form)
const f = await buche({ leistung: S_ANFRAGE, email: 'f@example.com', zeit: '14:00', vorzeitig: false })
await oeffne(page, 'v', f.vToken)
await warteH1(page, 'Anfrage bestätigen')
await knopf(page, 'Anfrage absenden').click()
await warteH1(page, 'Deine Anfrage ist eingegangen.')
await oeffne(page, 'v', f.vToken)
await warteH1(page, 'Deine Anfrage wurde bereits abgesendet.')
assert((await text(page)).includes('Du erhältst eine E-Mail, sobald Salon Müller sie annimmt.'), 'Du-Form beim zweiten Aufruf der Anfrage')
}, { eingabe: salonEingabe({ anrede: 'du' }) })
// ------------------------------------------------------------ 11. Farben und Fuss
@@ -494,7 +628,7 @@ async function pruefungen() {
eq(links, [
{ t: 'Impressum', href: 'https://salon-mueller.example/impressum', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Datenschutz', href: 'https://salon-mueller.example/datenschutz', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Impressum WEBklar', href: '/impressum.html', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Impressum WEBklar', href: 'https://webklar.com/impressum', rel: 'noopener noreferrer', target: '_blank' },
], 'Fusszeile')
assert((await page.locator('#fuss').innerText()).includes('Terminbuchung über WEBklar'), 'Hinweis "Terminbuchung über WEBklar"')
await page.locator('#fuss').scrollIntoViewIfNeeded()
@@ -540,6 +674,7 @@ async function pruefungen() {
assert(await passtInBreite(page), `${name}: Seite breiter als 390 px`)
const klein = await knoepfeGross(page)
eq(klein, [], `${name}: Knoepfe unter 40 px Hoehe`)
eq(await reihenfolgeFehler(page), [], `${name}: Tab-Reihenfolge entspricht nicht der Anzeige`)
}
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')