Portal: Mitarbeiter-Login gesperrt (Hinweis aufs Ticketsystem), alte Mitarbeiter-Sitzungen enden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:44:10 +00:00
1 parent d0ca3c41cd
commit 3a4fa581c0
13 files changed
+145 -559

No files matched your search

+5 -6
View File
@@ -10,7 +10,7 @@ const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('cust
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post } = await import('./support/testApp.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { seedFixtures, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
@@ -81,9 +81,8 @@ test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abge
assert.equal(logOf('abgelaufen').length, before + 1)
})
test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => {
const body = await get('/api/auth/me', { cookie: viewAsCookie('c1') })
assert.equal(body.viewAs, true)
assert.equal(body.viewId, undefined)
assert.equal(body.admin.name, 'Kenso')
test('/me alte Ansicht (viewAsCookie, ohne viewId): beendet, reason staff, Weiterleitung ins Ticketsystem', async () => {
const res = await me(viewAsCookie('c1'))
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.equal(res.headers.getSetCookie().some((c) => c.startsWith(`${config.cookieName}=`) && /Expires=Thu, 01 Jan 1970/.test(c)), true)
})
+1 -1
View File
@@ -97,7 +97,7 @@ test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', asy
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
checked += 1
}
assert.ok(checked >= 20, `nur ${checked} gesperrte Routen geprueft`)
assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`)
})
test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => {
+61 -73
View File
@@ -4,12 +4,13 @@ import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Portal-Login mit E-Mail-Code fuer Mitarbeiter mit Appwrite-2FA; Kunden und Mitarbeiter ohne 2FA wie bisher
// Portal-Login: Kunden wie bisher; Mitarbeiter (mit oder ohne 2FA) arbeiten im Ticketsystem (Etappe 4)
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { startTestApp, post, rawGet, sessionCookie } = await import('./support/testApp.js')
const { viewAsCookie } = await import('./support/fixtures.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
const app = await startTestApp({ '/api/auth': authRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -17,98 +18,85 @@ after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.addUser({ $id: 'u_kunde', email: 'kunde@example.invalid', password: 'kunde-kunde' })
aw.addUser({ $id: 'u_ohne', email: 'justin@webklar.com', password: 'ohne-ohne-ohne' })
aw.addUser({ $id: 'u_mfa', email: 'kenso@webklar.com', password: 'mit-mit-mit', mfa: true, emailVerification: true, labels: ['admin'] })
aw.addUser({ $id: 'u_label', email: 'label@webklar.com', password: 'label-label', labels: ['admin'] })
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u_kunde', email: 'kunde@example.invalid', name: 'Kunde', customerStatus: 'active', portalAccessEnabled: true })
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
aw.seed('employees', { $id: 'e1', userId: 'u_ohne', displayName: 'Justin', email: 'justin@webklar.com' })
aw.seed('employees', { $id: 'e2', userId: 'u_mfa', displayName: 'Kenso', email: 'kenso@webklar.com' })
const SESSION = config.cookieName
const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' }
const setCookies = (res) => res.headers.getSetCookie()
const cookieNamed = (res, name) => setCookies(res).find((c) => c.startsWith(`${name}=`)) || null
const jar = (res) => setCookies(res).map((c) => c.split(';')[0]).join('; ')
const cleared = (c) => /Expires=Thu, 01 Jan 1970/.test(c)
const liveCookies = (res, name) => setCookies(res).filter((c) => c.startsWith(`${name}=`) && !cleared(c))
const clears = (res, name) => setCookies(res).some((c) => c.startsWith(`${name}=`) && cleared(c))
const login = (email, password) => post('/api/auth/login', { email, password })
test('Kunde ohne 2FA: Anmeldung wie bisher', async () => {
test('Kunde meldet sich weiter normal an', async () => {
const before = aw.sessionDeletes()
const res = await login('kunde@example.invalid', 'kunde-kunde')
assert.equal(res.status, 200)
assert.equal(res.body.mfaRequired, undefined)
assert.ok(cookieNamed(res, SESSION))
assert.equal(res.body.role, 'customer')
assert.equal(liveCookies(res, SESSION).length, 1)
assert.equal(aw.sessionDeletes(), before)
})
test('Mitarbeiter ohne 2FA: Admin-Anmeldung wie bisher', async () => {
test('Mitarbeiter ohne 2FA: 403 mit Link ins Ticketsystem, keine Sitzung, Appwrite-Sitzung beendet', async () => {
const before = aw.sessionDeletes()
const res = await login('justin@webklar.com', 'ohne-ohne-ohne')
assert.equal(res.status, 200)
assert.equal(res.body.role, 'admin')
assert.ok(cookieNamed(res, SESSION))
})
test('Mitarbeiter mit 2FA: vor dem Code keine Portal-Sitzung', async () => {
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(res.status, 200)
assert.deepEqual(res.body, { mfaRequired: true, email: 'k…@webklar.com' })
assert.ok(cookieNamed(res, 'webklar_portal_mfa'))
assert.equal(cookieNamed(res, SESSION), null)
assert.equal(setCookies(res).length, 1)
const me = await rawGet('/api/auth/me', { cookie: jar(res) })
assert.equal(me.body.authenticated, false)
})
test('Richtiger Code: Admin-Sitzung, Code-Cookie geloescht', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) })
assert.equal(res.status, 200)
assert.equal(res.body.role, 'admin')
assert.equal(res.body.admin.isAdmin, true)
assert.ok(cookieNamed(res, SESSION))
assert.match(cookieNamed(res, 'webklar_portal_mfa'), /Expires=Thu, 01 Jan 1970/)
const me = await rawGet('/api/auth/me', { cookie: jar(res) })
assert.equal(me.body.role, 'admin')
})
test('Falscher Code: Hinweis, nach 5 Fehlversuchen neu anmelden', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
for (let i = 0; i < 5; i++) {
const res = await post('/api/auth/login/mfa', { otp: '000000' }, { cookie: jar(first) })
assert.equal(res.status, 401)
assert.equal(res.body.error, 'Code falsch oder abgelaufen.')
assert.equal(cookieNamed(res, SESSION), null)
}
const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) })
assert.equal(res.status, 410)
assert.equal(res.body.error, 'Bitte melde dich erneut an.')
})
test('Ohne Code-Cookie: neu anmelden', async () => {
const res = await post('/api/auth/login/mfa', { otp: '123456' })
assert.equal(res.status, 410)
const resend = await post('/api/auth/login/mfa/resend', {})
assert.equal(resend.status, 410)
})
test('Code erneut senden: zweite Mail', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
const before = aw.mailsSent('u_mfa')
const res = await post('/api/auth/login/mfa/resend', {}, { cookie: jar(first) })
assert.equal(res.status, 200)
assert.equal(aw.mailsSent('u_mfa'), before + 1)
})
test('2FA an, E-Mail nicht bestaetigt: Hinweis statt Sitzung', async () => {
aw.addUser({ $id: 'u_unbest', email: 'andrej@webklar.com', password: 'unbest-unbest', mfa: true, emailVerification: false })
aw.seed('employees', { $id: 'e3', userId: 'u_unbest', displayName: 'Andrej', email: 'andrej@webklar.com' })
const res = await login('andrej@webklar.com', 'unbest-unbest')
assert.equal(res.status, 403)
assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.')
assert.equal(cookieNamed(res, SESSION), null)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
assert.equal(liveCookies(res, 'preview_session').length, 0)
assert.equal(aw.sessionDeletes(), before + 1)
})
test('2FA-Pruefung schlaegt nicht offen fehl, wenn der Konto-Abruf scheitert', async () => {
test('Mitarbeiter mit 2FA: dieselbe Antwort, keine Code-Mail', async () => {
const before = aw.sessionDeletes()
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(res.status, 403)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(setCookies(res).filter((c) => !cleared(c)).length, 0)
assert.equal(aw.mailsSent('u_mfa'), 0)
assert.equal(aw.sessionDeletes(), before + 1)
})
test('Nur Label admin (ohne employees-Eintrag): ebenfalls gesperrt', async () => {
const res = await login('label@webklar.com', 'label-label')
assert.equal(res.status, 403)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
})
test('Konto-Abruf gestoert: Mitarbeiter bleibt gesperrt (employees-Treffer)', async () => {
aw.failUserLookups(true)
try {
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(cookieNamed(res, SESSION), null)
assert.equal(res.body.mfaRequired, true)
assert.equal(res.status, 403)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
} finally {
aw.failUserLookups(false)
}
})
test('/me mit alter Mitarbeiter-Sitzung: reason staff, Weiterleitung, Cookies geloescht', async () => {
const res = await rawGet('/api/auth/me', { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_mfa', name: 'Kenso', email: 'kenso@webklar.com' }) })
assert.equal(res.status, 200)
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.ok(clears(res, SESSION))
assert.ok(clears(res, 'preview_session'))
assert.equal(liveCookies(res, 'preview_session').length, 0)
})
test('/me mit Alt-Ansicht (viewAs ohne viewId): ebenso reason staff', async () => {
const res = await rawGet('/api/auth/me', { cookie: viewAsCookie('c1') })
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.ok(clears(res, SESSION))
})
test('/login/mfa, /login/mfa/resend und /staff-switch gibt es nicht mehr (404)', async () => {
assert.equal((await post('/api/auth/login/mfa', { otp: '123456' })).status, 404)
assert.equal((await post('/api/auth/login/mfa/resend', {})).status, 404)
assert.equal((await rawGet('/api/auth/staff-switch?token=abc')).status, 404)
})
-89
View File
@@ -1,89 +0,0 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Mitarbeiter-2FA per E-Mail-Code: Appwrite-Sitzung aus dem Login und offene Code-Anmeldungen
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { loginWithAppwrite, appwriteSessionFetch } = await import('../server/services/appwriteClient.js')
after(() => Promise.all([aw.close(), st.close()]))
aw.addUser({ $id: 'u_staff', email: 'kenso@webklar.com', password: 'geheim-geheim', mfa: true, emailVerification: true })
test('loginWithAppwrite liefert die Appwrite-Sitzung', async () => {
const user = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim')
assert.equal(user.$id, 'u_staff')
assert.equal(user.sessionCookie, 'sess-u_staff')
})
test('appwriteSessionFetch schickt Sitzung und Sprache', async () => {
const before = aw.mailsSent('u_staff')
const challenge = await appwriteSessionFetch('/account/mfa/challenges', {
method: 'POST',
body: { factor: 'email' },
sessionCookie: 'sess-u_staff',
})
assert.match(challenge.$id, /^ch/)
assert.equal(aw.mailsSent('u_staff'), before + 1)
assert.equal(aw.lastLocale(), 'de')
await assert.rejects(
appwriteSessionFetch('/account/mfa/challenges', { method: 'PUT', body: { challengeId: challenge.$id, otp: '000000' }, sessionCookie: 'sess-u_staff' }),
(err) => err.status === 401 && err.type === 'user_invalid_token'
)
})
const { maskEmail, startStaffMfa, verifyStaffMfa, resendStaffMfa } = await import('../server/services/staffMfa.js')
async function start() {
const { sessionCookie } = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim')
return startStaffMfa({ userId: 'u_staff', email: 'kenso@webklar.com', sessionCookie })
}
test('maskEmail: erstes Zeichen, Rest der Adresse ausgeblendet', () => {
assert.equal(maskEmail('kenso@webklar.com'), 'k…@webklar.com')
assert.equal(maskEmail('a@b.de'), 'a…@b.de')
assert.equal(maskEmail(''), '')
})
test('Richtiger Code meldet an, danach ist die Anmeldung verbraucht', async () => {
const { key, maskedEmail } = await start()
assert.equal(maskedEmail, 'k…@webklar.com')
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: true, userId: 'u_staff', email: 'kenso@webklar.com' })
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
test('Falscher Code: nach 5 Fehlversuchen ist die Anmeldung weg', async () => {
const { key } = await start()
for (let i = 0; i < 5; i++) assert.deepEqual(await verifyStaffMfa(key, '000000'), { ok: false, reason: 'invalid' })
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
test('Nach 10 Minuten ist die Anmeldung abgelaufen', async (t) => {
const { key } = await start()
const now = Date.now()
t.mock.method(Date, 'now', () => now + 11 * 60 * 1000)
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
test('Code erneut senden: neue Mail, nur der neue Code gilt', async () => {
const { key } = await start()
const oldOtp = aw.lastOtp('u_staff')
const before = aw.mailsSent('u_staff')
assert.equal(await resendStaffMfa(key), true)
assert.equal(aw.mailsSent('u_staff'), before + 1)
const newOtp = aw.lastOtp('u_staff')
if (oldOtp !== newOtp) assert.deepEqual(await verifyStaffMfa(key, oldOtp), { ok: false, reason: 'invalid' })
assert.equal((await verifyStaffMfa(key, newOtp)).ok, true)
assert.equal(await resendStaffMfa(key), false)
})
test('Parallele Fehlversuche: hoechstens 5 Versuche zaehlen', async () => {
const { key } = await start()
const results = await Promise.all(Array.from({ length: 10 }, () => verifyStaffMfa(key, '000000')))
assert.equal(results.filter((r) => r.reason === 'invalid').length, 5)
assert.equal(results.filter((r) => r.reason === 'gone').length, 5)
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
-33
View File
@@ -45,39 +45,6 @@ test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () =>
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/)
})
test('staff-switch: gueltig -> Admin-Sitzung und /admin.html, zweites Mal -> Login', async () => {
const { body } = await switchLink('jwt-staff')
const path = `/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`
const res = await rawGet(path)
assert.equal(res.status, 302)
assert.equal(res.headers.get('location'), '/admin.html')
const session = portalSession(res)
assert.equal(session.role, 'admin')
assert.equal(session.isAdmin, false)
assert.equal(session.appwriteUserId, 'u_staff2')
const again = await rawGet(path)
assert.equal(again.status, 302)
assert.equal(again.headers.get('location'), '/login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden')
assert.equal(portalSession(again), null)
})
test('staff-switch: nach 61 Sekunden abgelaufen', async (t) => {
const { body } = await switchLink('jwt-staff')
const now = Date.now()
t.mock.method(Date, 'now', () => now + 61 * 1000)
const res = await rawGet(`/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`)
assert.match(res.headers.get('location'), /^\/login\.html\?error=/)
})
test('Tokens nicht vertauschbar: Switch ueber staff-preview, Preview ueber staff-switch', async () => {
const { body } = await switchLink('jwt-staff')
assert.equal((await rawGet(`/api/auth/staff-preview?token=${tokenOf(body.openUrl)}`)).status, 410)
const previewToken = createStaffPreviewToken({ userId: 'u_admin', email: 'kenso@webklar.com', name: 'Kenso', previewUrl: 'https://x.project.webklar.com/' })
const res = await rawGet(`/api/auth/staff-switch?token=${previewToken}`)
assert.match(res.headers.get('location'), /^\/login\.html\?error=/)
assert.equal(portalSession(res), null)
})
test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => {
const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8')
const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"'))
+10
View File
@@ -21,6 +21,7 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const userCalls = []
let lastLocale = null
let failUserLookups = false
let sessionDeletes = 0
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
@@ -117,6 +118,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const project = req.headers['x-appwrite-project'] || 'x'
return [201, { $id: `s-${user.$id}`, userId: user.$id }, { 'Set-Cookie': `a_session_${project}=${secret}; path=/; httponly` }]
}
// Abmelden der eben erzeugten Sitzung (Portal-Login lehnt Mitarbeiter ab)
if (url.pathname === '/v1/account/sessions/current' && req.method === 'DELETE') {
const match = String(req.headers.cookie || '').match(/a_session_[^=]+=([^;]+)/)
if (!match || !sessions.delete(decodeURIComponent(match[1]))) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }]
sessionDeletes += 1
return [204, null]
}
if (url.pathname === '/v1/account/mfa/challenges') {
const session = sessionOf(req)
if (!session) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }]
@@ -256,6 +264,8 @@ export async function startAppwriteMock({ schema = {} } = {}) {
lastOtp: (userId) => [...challenges.values()].filter((c) => c.userId === userId).at(-1)?.otp || null,
expireChallenges: () => challenges.forEach((c) => (c.expired = true)),
mailsSent: (userId) => mails.get(userId) || 0,
/** Zahl der beendeten Appwrite-Sitzungen (DELETE /account/sessions/current) */
sessionDeletes: () => sessionDeletes,
lastLocale: () => lastLocale,
/** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */
failUserLookups: (on) => (failUserLookups = Boolean(on)),
+25 -6
View File
@@ -31,8 +31,11 @@ const VIEW = 'a'.repeat(32)
const customer = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const ME = {
neu: { authenticated: true, role: 'admin', viewAs: true, viewId: VIEW, viewExpiresAt: Date.now() + 3600000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer },
alt: { authenticated: true, role: 'admin', viewAs: true, admin: { name: 'Kenso', email: 'kenso@webklar.com', isAdmin: true }, customer },
// alte Mitarbeiter-Sitzung (Login im Portal oder Alt-Ansicht ohne Kennung): /me beendet sie, weiter ins Ticketsystem
staff: { authenticated: false, reason: 'staff', redirect: `${base}/zurueck-im-ticketsystem.html?von=staff` },
gast: { authenticated: false },
}
const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' }
let scenario = 'neu'
let replaceAfterMe = false
@@ -50,6 +53,7 @@ async function mockApi(page) {
seen.ende += 1
return json({ redirect: `${base}/zurueck-im-ticketsystem.html` })
}
if (p === '/api/auth/login') return json(STAFF_BLOCKED, 403)
if (p === '/api/chat/read') seen.chatRead += 1
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
@@ -104,11 +108,26 @@ try {
await page.screenshot({ path: `${SHOTS}/${label}-ersetzt.png` })
replaceAfterMe = false
// 3) alte Ansicht aus dem Admin-Panel: altes Banner, keine Kennung
await open('alt')
const old = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim()
check(`${label} altes_banner`, old.includes('Admin-Ansicht') && old.includes('Zurück zur Admin-Übersicht'), old)
check(`${label} alt_ohne_kennung`, seen.headers.every((h) => h === null), JSON.stringify(seen.headers))
// 3) alte Mitarbeiter-Sitzung: Dashboard leitet ins Ticketsystem weiter
scenario = 'staff'
await page.goto('about:blank')
await Promise.all([page.waitForURL(/zurueck-im-ticketsystem\.html\?von=staff/), page.goto(`${base}/dashboard.html`)])
check(`${label} staff_weiterleitung`, page.url().endsWith('/zurueck-im-ticketsystem.html?von=staff'), page.url())
// 4) Mitarbeiter meldet sich im Portal an: Hinweis und Link ins Ticketsystem
scenario = 'gast'
await page.goto(`${base}/login.html`)
await page.fill('#email', 'andrej@webklar.com')
await page.fill('#password', 'geheim-geheim')
await page.click('#login-btn')
await page.waitForSelector('#login-staff-link:not(.hidden)', { timeout: 15000 }).catch(() => null)
const hint = (await page.textContent('#login-error')).trim()
check(`${label} login_hinweis`, hint === 'Mitarbeiter arbeiten im Ticketsystem.', hint)
const link = await page.$eval('#login-staff-link', (a) => ({ text: a.textContent.trim(), href: a.getAttribute('href'), visible: a.offsetParent !== null }))
check(`${label} login_link_ticketsystem`, link.visible && link.text === 'Zum Ticketsystem' && link.href === 'https://ticket.webklar.com', JSON.stringify(link))
check(`${label} kein_mfa_formular`, (await page.$('#mfa-form')) === null)
await page.waitForTimeout(1200)
await page.screenshot({ path: `${SHOTS}/${label}-login-mitarbeiter.png` })
check(`${label} keine_js_fehler`, errors.length === 0, errors.join(' | '))
await ctx.close()