Terminplaner: Gaesteseiten – Nachbesserungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
734e397e73
commit
70940e02c8
5 files changed
+193
-33
No files matched your search
+4
-5
@@ -40,7 +40,6 @@ body {
|
||||
.t-karte { background: var(--t-karte); border: 1px solid var(--t-rand); border-radius: var(--t-radius); padding: 22px 20px; }
|
||||
|
||||
.t-titel { margin: 0 0 .75rem; font-size: 1.45rem; line-height: 1.25; font-weight: 700; }
|
||||
.t-titel:focus, .t-titel:focus-visible { outline: none; } /* nur zum Anspringen per Skript, kein Bedienelement */
|
||||
.t-karte p { margin: 0 0 .85rem; }
|
||||
.t-laden { opacity: .8; }
|
||||
|
||||
@@ -76,9 +75,9 @@ body {
|
||||
.t-btn-sek:hover { background: var(--t-weich); filter: none; }
|
||||
.t-btn-warn { background: #b91c1c; color: #ffffff; border-color: #b91c1c; }
|
||||
.t-btn[disabled] { opacity: .6; cursor: default; }
|
||||
.t-link { font: inherit; color: inherit; background: none; border: 0; padding: .35rem 0; min-height: 44px; text-decoration: underline; cursor: pointer; }
|
||||
.t-weitere { margin: 1.1rem 0 0; }
|
||||
.t-karte :focus-visible, .t-fuss :focus-visible { outline: 3px solid var(--t-text); outline-offset: 2px; }
|
||||
/* Die Ueberschrift wird nur per Skript angesprungen (tabindex -1), sie ist kein Bedienelement: kein Rahmen */
|
||||
.t-karte .t-titel:focus, .t-karte .t-titel:focus-visible { outline: none; }
|
||||
|
||||
/* Kontakt (abgelaufen) */
|
||||
.t-kontakt { margin: 0 0 .85rem; padding: 0; list-style: none; display: grid; gap: .4rem; }
|
||||
@@ -127,7 +126,7 @@ body {
|
||||
.t-karte { padding: 18px 16px; }
|
||||
.t-zeile { grid-template-columns: 1fr; gap: 0; }
|
||||
.t-zeile dd { margin-bottom: .35rem; }
|
||||
.t-nav { flex-direction: column-reverse; align-items: stretch; }
|
||||
.t-nav { flex-direction: column; align-items: stretch; } /* Reihenfolge im DOM = Reihenfolge auf dem Bildschirm */
|
||||
.t-nav .t-btn { width: 100%; }
|
||||
.t-raster { border-spacing: 2px; }
|
||||
.t-tag { height: 40px; }
|
||||
@@ -140,7 +139,7 @@ body {
|
||||
}
|
||||
|
||||
@media print {
|
||||
.t-kopf, .t-nav, .t-weitere { display: none; }
|
||||
.t-kopf, .t-nav { display: none; }
|
||||
body { background: #fff; }
|
||||
.t-karte { border: 0; }
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
<footer class="t-fuss" id="fuss">
|
||||
<div class="t-innen">
|
||||
<p class="t-links" id="fuss-links"></p>
|
||||
<p class="t-powered"><a href="/impressum.html" target="_blank" rel="noopener noreferrer">Impressum WEBklar</a> · Terminbuchung über WEBklar</p>
|
||||
<p class="t-powered"><a href="https://webklar.com/impressum" target="_blank" rel="noopener noreferrer">Impressum WEBklar</a> · Terminbuchung über WEBklar</p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/termin.js" defer></script>
|
||||
|
||||
+28
-22
@@ -161,12 +161,6 @@
|
||||
return m
|
||||
}
|
||||
|
||||
function absaetze (text, cls) {
|
||||
const wrap = el('div', cls || '')
|
||||
String(text).split(/\n{2,}/).forEach(function (a) { if (a.trim()) wrap.appendChild(el('p', '', a.trim())) })
|
||||
return wrap
|
||||
}
|
||||
|
||||
// Zusammenfassung des Termins (alles vom Server, nur Texte)
|
||||
function zusammenfassung (t) {
|
||||
const dl = el('dl', 't-zusammen')
|
||||
@@ -246,11 +240,22 @@
|
||||
return k.length ? fehler[k[0]] : vorgabe
|
||||
}
|
||||
|
||||
// Kopf und Fuss einer Fehleransicht: Ist die Vorschau schon geladen (der Link laeuft mitten in der Sitzung ab), bleiben
|
||||
// Farbe, Anrede und Fusslinks, wie der Gast sie gesehen hat; sonst neutraler Kopf, Sie-Form, kein Betriebsfuss.
|
||||
function fehlerKopf (name) {
|
||||
if (S.v) {
|
||||
setzeKopf(name || S.v.betrieb.name, S.v.farbe)
|
||||
setzeFuss(S.v.betrieb)
|
||||
return
|
||||
}
|
||||
S.du = false
|
||||
setzeKopf(name, '')
|
||||
setzeFuss(null)
|
||||
}
|
||||
|
||||
// Link unbekannt, unvollstaendig oder ohne Hash
|
||||
function zeigeUngueltig (text, hinweis) {
|
||||
S.du = false
|
||||
setzeKopf('', '')
|
||||
setzeFuss(null)
|
||||
fehlerKopf('')
|
||||
zeichne(function () {
|
||||
add(titel(text))
|
||||
add(el('p', '', hinweis))
|
||||
@@ -260,8 +265,7 @@
|
||||
// Abgelaufen: die Antwort traegt Name, Telefon und Website des Betriebs
|
||||
function zeigeAbgelaufen (err) {
|
||||
const b = err.betrieb || {}
|
||||
setzeKopf(b.name, '')
|
||||
setzeFuss(null)
|
||||
fehlerKopf(b.name)
|
||||
zeichne(function () {
|
||||
add(titel(ersterText(err.fehler, 'Diese Reservierung ist abgelaufen.')))
|
||||
add(el('p', '', T('Bitte buchen Sie Ihren Termin noch einmal oder rufen Sie uns an.', 'Bitte buche deinen Termin noch einmal oder ruf uns an.')))
|
||||
@@ -318,8 +322,15 @@
|
||||
|
||||
function start () {
|
||||
const roh = window.location.hash
|
||||
// Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile
|
||||
if (roh) window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
S.v = null
|
||||
S.verschieben = null
|
||||
// Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile;
|
||||
// wirft der Browser dabei (eingeschraenkte Umgebung), laeuft die Seite trotzdem weiter
|
||||
if (roh) {
|
||||
try {
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
} catch (e) { /* Token bleibt dann in der Adresszeile, die Seite funktioniert */ }
|
||||
}
|
||||
if (!roh) {
|
||||
zeigeUngueltig('Bitte öffnen Sie den Link aus Ihrer E-Mail', 'Diese Seite wird über den Link in der E-Mail zu Ihrem Termin aufgerufen. Bitte öffnen Sie diese E-Mail und klicken Sie dort noch einmal auf den Link.')
|
||||
return
|
||||
@@ -330,8 +341,6 @@
|
||||
return
|
||||
}
|
||||
S.token = m[1]
|
||||
S.v = null
|
||||
S.verschieben = null
|
||||
lade()
|
||||
}
|
||||
|
||||
@@ -408,11 +417,11 @@
|
||||
break
|
||||
case 'angefragt':
|
||||
ok = true
|
||||
t = neu ? T('Ihre Anfrage ist eingegangen.', 'Deine Anfrage ist eingegangen.') : 'Diese Anfrage ist bereits bestätigt.'
|
||||
t = neu ? T('Ihre Anfrage ist eingegangen.', 'Deine Anfrage ist eingegangen.') : T('Ihre Anfrage wurde bereits abgesendet.', 'Deine Anfrage wurde bereits abgesendet.')
|
||||
text = neu
|
||||
? T(betrieb + ' prüft Ihre Anfrage und meldet sich bei Ihnen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.',
|
||||
betrieb + ' prüft deine Anfrage und meldet sich bei dir. Eine Eingangsbestätigung haben wir dir per E-Mail geschickt.')
|
||||
: T('Sie müssen nichts weiter tun. ' + betrieb + ' meldet sich bei Ihnen.', 'Du musst nichts weiter tun. ' + betrieb + ' meldet sich bei dir.')
|
||||
: T('Sie erhalten eine E-Mail, sobald ' + betrieb + ' sie annimmt.', 'Du erhältst eine E-Mail, sobald ' + betrieb + ' sie annimmt.')
|
||||
break
|
||||
case 'abgesagt':
|
||||
t = 'Dieser Termin wurde abgesagt.'
|
||||
@@ -465,12 +474,9 @@
|
||||
const nav = el('div', 't-nav')
|
||||
if (a.verschieben) nav.appendChild(knopf('t-btn', 'Termin verschieben', function () { zeigeVerschieben() }))
|
||||
if (a.absagen) nav.appendChild(knopf('t-btn t-btn-sek', 'Termin absagen', zeigeAbsageFrage))
|
||||
// § 356a BGB: die Widerrufsfunktion ist gut sichtbar und leicht erreichbar, ein gleichrangiger Knopf in derselben Reihe
|
||||
if (a.widerrufen) nav.appendChild(knopf('t-btn t-btn-sek', 'Vertrag widerrufen', zeigeWiderruf))
|
||||
if (nav.childNodes.length) add(nav)
|
||||
if (a.widerrufen) {
|
||||
const w = el('p', 't-weitere')
|
||||
w.appendChild(knopf('t-link', 'Vertrag widerrufen', zeigeWiderruf))
|
||||
add(w)
|
||||
}
|
||||
const offen = v.status === 'bestaetigt' || v.status === 'angefragt'
|
||||
if (offen && !a.absagen && !a.verschieben && !a.widerrufen && !(istText(v.hinweis) && v.hinweis)) {
|
||||
add(el('p', 't-box', 'Dieser Termin kann online nicht mehr geändert werden.' +
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>&</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')
|
||||
|
||||
Reference in new issue
Block a user