From 777d5272bf137b0be3d697c01e1218e7c36b2b92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:36:00 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P3:=20Nachtr=C3=A4ge=20aus=20der=20P2?= =?UTF-8?q?-Endpr=C3=BCfung=20(Kennungen,=20JWT-Sperre,=20Abmelden,=20Best?= =?UTF-8?q?=C3=A4tigung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/WorkersBoard.jsx | 44 ++++++- src/context/AuthContext.jsx | 2 + src/lib/workersApi.js | 45 +++++-- src/lib/workersClient.js | 7 ++ tests/unit/workersApi.test.mjs | 156 +++++++++++++++++++++++- tests/unit/workersComponents.test.mjs | 44 +++++++ 6 files changed, 279 insertions(+), 19 deletions(-) diff --git a/src/components/workers/WorkersBoard.jsx b/src/components/workers/WorkersBoard.jsx index dfb7bab..cf86356 100644 --- a/src/components/workers/WorkersBoard.jsx +++ b/src/components/workers/WorkersBoard.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import Button from '../ui/Button' +import Dialog from '../ui/Dialog' import EmptyState from '../ui/EmptyState' import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed' import { indexWorkers, reduceEvents } from '../../lib/workersEvents' @@ -34,6 +35,7 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { const [settingsOpen, setSettingsOpen] = useState(false) const [pausing, setPausing] = useState(false) const [pauseError, setPauseError] = useState('') + const [confirmOpen, setConfirmOpen] = useState(false) useEffect(() => { aliveRef.current = true @@ -53,6 +55,12 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId) + // Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen + const haltedByState = Boolean(state?.paused) + useEffect(() => { + if (haltedByState) setConfirmOpen(false) + }, [haltedByState]) + if (!loaded || !state) { if (error) { return ( @@ -82,13 +90,15 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { } } - // Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert, danach wird der Zustand sofort neu geholt - const togglePause = async () => { + // Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert (true = anhalten), danach wird der Zustand sofort neu geholt. + // Der Zielwert wird übergeben und nicht aus dem Zustand abgeleitet: ein offener Dialog darf nie fortsetzen, falls inzwischen + // jemand anderes angehalten hat. + const setPause = async (target) => { if (pausing) return setPausing(true) setPauseError('') try { - await api.pause(!paused) + await api.pause(target) refresh() } catch (err) { if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT) @@ -96,12 +106,22 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { if (aliveRef.current) setPausing(false) } + // Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage + const requestPause = () => { + if (paused) setPause(false) + else setConfirmOpen(true) + } + const confirmHalt = () => { + setConfirmOpen(false) + setPause(true) + } + return ( <>
{paused ? PAUSED_TEXT : RUNNING_TEXT} - + {isAdmin && }
@@ -133,6 +153,22 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { {isAdmin && settingsOpen && ( setSettingsOpen(false)} onSaved={() => refresh()} /> )} + + {confirmOpen && !paused && ( + setConfirmOpen(false)} + footer={ + <> + + + + + } + > +

Die Worker hören sofort auf, bis jemand fortsetzt.

+
+ )} ) } diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index e3a16fd..7f60941 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -12,6 +12,7 @@ import { } from '../lib/appwrite' import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa' import { isStaffAccount, NO_STAFF_ACCESS } from '../lib/staffAccess' +import { resetWorkersJwt } from '../lib/workersClient' const AuthContext = createContext() const mfa = createMfaApi(client) @@ -238,6 +239,7 @@ export function AuthProvider({ children }) { console.error('Logout error:', error) } finally { clearAllLocalAppwriteSessions() + resetWorkersJwt() // JWT und Sperre der Hustler-Seite gehören zur beendeten Sitzung setUser(null) setEmployee(null) setMfaPending(null) diff --git a/src/lib/workersApi.js b/src/lib/workersApi.js index fb13635..1a23e02 100644 --- a/src/lib/workersApi.js +++ b/src/lib/workersApi.js @@ -6,13 +6,15 @@ export const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.' export const BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.' export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.' export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.' +export const INVALID_ID_TEXT = 'Ungültige Kennung.' // Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand, // Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung. export const REQUEST_TIMEOUT_MS = 20000 // Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder lässt sich nicht erzeugen) wird so lange kein frisches JWT mehr -// erzwungen: Appwrite begrenzt createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen -// und damit auch Serverpuls, Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab. +// erzwungen, und scheitert createJWT selbst (z. B. Appwrite 429), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt +// createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls, +// Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab. export const AUTH_COOLDOWN_MS = 120000 // Pult-Status des Dienstes (Übersicht §4.11) @@ -26,6 +28,13 @@ const TRACE_LIMIT = 50 const statusError = (message, status) => Object.assign(new Error(message), { status }) +// Kennung für einen Pfadabschnitt: nur eine nichtleere Zeichenkette, und nie '.' oder '..' (encodeURIComponent lässt beide stehen, +// der Pfad würde zu einem anderen Abschnitt der API führen). Alles andere wird kodiert, damit '/', '?' und '#' im Abschnitt bleiben. +function pathId(id) { + if (typeof id !== 'string' || id === '' || id === '.' || id === '..') throw statusError(INVALID_ID_TEXT, 400) + return encodeURIComponent(id) +} + // Query aus Paaren; leere Werte (undefined, null, '') fallen weg, der Rest wird kodiert function query(pairs) { const parts = [] @@ -61,7 +70,8 @@ export function createWorkersApi({ authCooldownMs = AUTH_COOLDOWN_MS, now = Date.now, }) { - let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Antwort kein frisches JWT erzeugt (AUTH_COOLDOWN_MS) + let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS) + let mintFailed = false // getJwt (createJWT) ist gescheitert: bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache // Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird // abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung). @@ -89,9 +99,17 @@ export function createWorkersApi({ } async function jwtFor(options) { + if (mintFailed) { + if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401) // gesperrt: kein createJWT, keine Anfrage + mintFailed = false // Sperre vorbei: genau ein neuer Versuch + } try { return await getJwt(options) } catch { + // createJWT selbst scheitert (z. B. Appwrite 429, Sitzung weg): dieselbe Sperre wie nach der zweiten 401. + // Ein hängendes getJwt zählt nicht dazu: es endet in withDeadline mit der Dienst-Meldung, bevor dieser Zweig erreicht wird. + mintFailed = true + freshBlockedUntil = now() + authCooldownMs throw statusError(SESSION_TEXT, 401) } } @@ -130,13 +148,9 @@ export function createWorkersApi({ if (out.unauthorized) { // Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage) if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401) - // sonst einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen und die Sperre beginnt - try { - out = await attempt(path, method, body, { fresh: true }) - } catch (err) { - if (err && err.status === 401) freshBlockedUntil = now() + authCooldownMs // auch das frische JWT ließ sich nicht erzeugen - throw err - } + // sonst einmal mit frischem JWT wiederholen (lässt es sich nicht erzeugen, beginnt die Sperre in jwtFor), danach gilt die + // Sitzung als abgelaufen und die Sperre beginnt + out = await attempt(path, method, body, { fresh: true }) if (out.unauthorized) { freshBlockedUntil = now() + authCooldownMs throw statusError(SESSION_TEXT, 401) @@ -148,7 +162,7 @@ export function createWorkersApi({ return { state: () => request('/state'), events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`), - trace: (workerId, limit = TRACE_LIMIT) => request(`/worker/${encodeURIComponent(workerId)}/trace${query([['limit', clampLimit(limit, TRACE_LIMIT)]])}`), + trace: async (workerId, limit = TRACE_LIMIT) => request(`/worker/${pathId(workerId)}/trace${query([['limit', clampLimit(limit, TRACE_LIMIT)]])}`), pause: (paused) => request('/pause', { method: 'POST', body: { paused: Boolean(paused) } }), settings: () => request('/settings'), saveSettings: (patch) => request('/settings', { method: 'PUT', body: patch }), @@ -156,9 +170,14 @@ export function createWorkersApi({ pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`), pultAction: async (id, action, body) => { if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400) - return request(`/pult/${encodeURIComponent(id)}/${action}`, { method: 'POST', body }) + return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body }) }, botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`), - prospect: (id) => request(`/prospect/${encodeURIComponent(id)}`), + prospect: async (id) => request(`/prospect/${pathId(id)}`), + // Beim Abmelden: Sperre aufheben, damit die nächste Anmeldung sofort wieder ein JWT holen darf (den JWT-Cache leert workersClient.js) + resetAuth: () => { + freshBlockedUntil = 0 + mintFailed = false + }, } } diff --git a/src/lib/workersClient.js b/src/lib/workersClient.js index b14bc70..43a947b 100644 --- a/src/lib/workersClient.js +++ b/src/lib/workersClient.js @@ -12,3 +12,10 @@ const getJwt = (options) => { } export const workersApi = createWorkersApi({ getJwt }) + +// Beim Abmelden aufgerufen (AuthContext): das JWT des abgemeldeten Nutzers darf nicht im Cache bleiben, und eine Sperre der +// alten Sitzung (zwei 401 hintereinander oder ein gescheitertes createJWT) darf die nächste Anmeldung nicht aufhalten. +export function resetWorkersJwt() { + jwtCache.invalidate() + workersApi.resetAuth() +} diff --git a/tests/unit/workersApi.test.mjs b/tests/unit/workersApi.test.mjs index 1d91a8d..6bebe7e 100644 --- a/tests/unit/workersApi.test.mjs +++ b/tests/unit/workersApi.test.mjs @@ -4,6 +4,7 @@ import { loadModule } from './load.mjs' // API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS } = await loadModule('src/lib/workersApi.js') +const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.js') const jwts = [] const getJwt = async (o) => { @@ -256,10 +257,10 @@ test('401-Folge: scheitert schon die Erzeugung des frischen JWT, beginnt dieselb await assert.rejects(api.state(), { message: SESSION, status: 401 }) t += 10000 await assert.rejects(api.state(), { message: SESSION, status: 401 }) - assert.deepEqual(mints, [false, true, false, false]) // ein einziger Versuch, ein frisches JWT zu erzeugen + assert.deepEqual(mints, [false, true]) // ein einziger Versuch, ein frisches JWT zu erzeugen; danach bis zum Ende der Sperre gar keiner mehr (Endprüfung P3) t += 60000 await assert.rejects(api.state(), { message: SESSION, status: 401 }) - assert.deepEqual(mints, [false, true, false, false, false, true]) + assert.deepEqual(mints, [false, true, false, true]) // nach der Sperre genau ein neuer Versuch (gespeichertes JWT, 401, frisches JWT scheitert wieder) }) test('401-Folge: andere Fehler (403, 500, 502) heben die Sperre auf, weil der Dienst das JWT angenommen hat', async () => { @@ -275,3 +276,154 @@ test('401-Folge: andere Fehler (403, 500, 502) heben die Sperre auf, weil der Di assert.deepEqual(await api.state(), {}) // 401, Wiederholung frisch ⇒ 200 assert.deepEqual(jwts, [false, true, false, false, true]) }) + +// ---- P3-T21: Kennungen im Pfad, Sperre auch beim Scheitern von createJWT, Zurücksetzen beim Abmelden ---- + +test('Kennungen im Pfad: leer, ".", ".." oder keine Zeichenkette ⇒ "Ungültige Kennung." ohne Netzaufruf', async () => { + const f = fakeFetch(okReplies(4)) + const api = createWorkersApi({ getJwt, fetchImpl: f }) + const bad = [['pultAction', '..', 'claim'], ['pultAction', '.', 'sent'], ['pultAction', '', 'done'], ['pultAction', null, 'claim'], ['pultAction', 7, 'claim'], + ['prospect', ''], ['prospect', '.'], ['prospect', '..'], ['prospect', undefined], ['prospect', { toString: () => 'x' }], + ['trace', ''], ['trace', '..'], ['trace', '.'], ['trace', null], ['trace', ['kim']]] + for (const [method, ...args] of bad) { + const out = api[method](...args) + assert.ok(out instanceof Promise, `${method}(${String(args[0])}) liefert ein Promise, wirft nicht synchron`) + await assert.rejects(out, { message: 'Ungültige Kennung.' }, `${method}(${String(args[0])})`) + } + assert.equal(f.calls.length, 0) + // sonst wird kodiert, auch Schrägstriche und Prozentzeichen; Punkte allein sind nur als ganze Kennung verboten + await api.prospect('a/b') + await api.pultAction('a b%', 'claim') + await api.trace('k/../m') + await api.prospect('p..1') + assert.deepEqual(f.calls.map((c) => c.url), [ + '/api/workers/prospect/a%2Fb', + '/api/workers/pult/a%20b%25/claim', + '/api/workers/worker/k%2F..%2Fm/trace?limit=50', + '/api/workers/prospect/p..1', + ]) +}) + +test('Kennungen im Pfad: die Aktionsprüfung bleibt vorn (unbekannte Aktion wird weiter als solche gemeldet)', async () => { + const f = fakeFetch([]) + const api = createWorkersApi({ getJwt, fetchImpl: f }) + await assert.rejects(api.pultAction('p1', 'delete'), /Unbekannte Aktion/) + await assert.rejects(api.pultAction('..', 'delete'), /Unbekannte Aktion/) + assert.equal(f.calls.length, 0) +}) + +// Verdrahtung wie in workersClient.js: echter JWT-Cache, `fresh` verwirft ihn vor dem Holen +function cachedJwt(createJWT, now) { + const cache = createJwtCache({ createJWT, now }) + return (options) => { + if (options?.fresh) cache.invalidate() + return cache.get() + } +} + +test('createJWT scheitert (Appwrite 429): Sperre wie nach der zweiten 401, auch bei leerem Cache; danach genau ein neuer Versuch', async () => { + let t = 0 + let creates = 0 + let failing = true + const getJwtCached = cachedJwt(async () => { + creates += 1 + if (failing) throw Object.assign(new Error('Rate limit'), { code: 429 }) + return { jwt: `jwt-${creates}` } + }, () => t) + const f = fakeFetch(okReplies(8)) + const api = createWorkersApi({ getJwt: getJwtCached, fetchImpl: f, now: () => t, authCooldownMs: 60000 }) + + await assert.rejects(api.state(), { message: SESSION, status: 401 }) + assert.equal(creates, 1) + // Schleifen (5 s / 15 s) innerhalb der Sperre: kein weiterer createJWT-Aufruf, keine Anfrage an den Dienst, immer SESSION_TEXT + for (let i = 0; i < 11; i += 1) { + t += 5000 + await assert.rejects([api.state, api.events, api.settings][i % 3](), { message: SESSION, status: 401 }) + } + assert.equal(t, 55000) + assert.equal(creates, 1) + assert.equal(f.calls.length, 0) + + // Sperre vorbei: genau ein Versuch, er scheitert wieder ⇒ neue Sperre + t += 6000 + await assert.rejects(api.state(), { message: SESSION, status: 401 }) + assert.equal(creates, 2) + t += 30000 + await assert.rejects(api.state(), { message: SESSION, status: 401 }) + assert.equal(creates, 2) + + // Appwrite erholt sich: nach der Sperre klappt der nächste Versuch, das JWT wird gecacht und der Dienst erreicht + failing = false + t += 31000 + assert.deepEqual(await api.state(), {}) + assert.equal(creates, 3) + assert.equal(f.calls.length, 1) + assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-3') + assert.deepEqual(await api.state(), {}) + assert.equal(creates, 3) +}) + +test('createJWT scheitert beim frischen JWT nach einer 401: der leere Cache löst innerhalb der Sperre kein weiteres createJWT aus', async () => { + let t = 0 + let creates = 0 + const getJwtCached = cachedJwt(async () => { + creates += 1 + if (creates >= 2) throw Object.assign(new Error('Rate limit'), { code: 429 }) // das erste JWT klappt, jedes weitere nicht + return { jwt: 'jwt-1' } + }, () => t) + const f = fakeFetch([{ status: 401, body: {} }, ...okReplies(4)]) + const api = createWorkersApi({ getJwt: getJwtCached, fetchImpl: f, now: () => t, authCooldownMs: 60000 }) + await assert.rejects(api.state(), { message: SESSION, status: 401 }) // 401, dann scheitert das frische JWT (Cache ist danach leer) + assert.equal(creates, 2) + assert.equal(f.calls.length, 1) + for (let i = 0; i < 5; i += 1) { + t += 10000 + await assert.rejects(api.events(null), { message: SESSION, status: 401 }) + } + assert.equal(creates, 2) + assert.equal(f.calls.length, 1) + t += 20000 // 70 s nach dem Scheitern: Sperre vorbei + await assert.rejects(api.state(), { message: SESSION, status: 401 }) + assert.equal(creates, 3) +}) + +test('resetAuth (Abmelden): hebt die Sperre auf, der nächste Abruf holt sofort wieder ein JWT', async () => { + let t = 0 + const asked = [] + const failing = { on: true } + const getJwtFlaky = async (o) => { + asked.push(o?.fresh === true) + if (failing.on) throw new Error('429') + return 'jwt-neu' + } + const f = fakeFetch(okReplies(2)) + const api = createWorkersApi({ getJwt: getJwtFlaky, fetchImpl: f, now: () => t, authCooldownMs: 60000 }) + await assert.rejects(api.state(), { message: SESSION, status: 401 }) + t += 1000 + await assert.rejects(api.state(), { message: SESSION, status: 401 }) + assert.deepEqual(asked, [false]) // gesperrt + failing.on = false + assert.equal(typeof api.resetAuth, 'function') + api.resetAuth() + assert.deepEqual(await api.state(), {}) + assert.deepEqual(asked, [false, false]) + assert.equal(f.calls.length, 1) + + // auch die Sperre nach einer 401-Folge fällt: ein neues einzelnes 401 darf wieder mit frischem JWT wiederholt werden + const g = fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }, { status: 401, body: {} }, { status: 200, body: { ok: 1 } }]) + const api2 = createWorkersApi({ getJwt, fetchImpl: g, now: () => t, authCooldownMs: 60000 }) + jwts.length = 0 + await assert.rejects(api2.state(), { status: 401 }) // 401 + 401 ⇒ Sperre + api2.resetAuth() + assert.deepEqual(await api2.state(), { ok: 1 }) + assert.deepEqual(jwts, [false, true, false, true]) +}) + +test('ein hängendes createJWT (Zeitgrenze) ist kein Scheitern und löst keine Sperre aus', async () => { + let asked = 0 + const hung = () => { asked += 1; return new Promise(() => {}) } + const api = createWorkersApi({ getJwt: hung, fetchImpl: fakeFetch([]), timeoutMs: 20 }) + await assert.rejects(api.state(), { message: DOWN, status: 0 }) + await assert.rejects(api.state(), { message: DOWN, status: 0 }) + assert.equal(asked, 2) // die Zeitgrenze (Netz/Ruhezustand) sperrt nichts: ein nächster Abruf versucht es wieder +}) diff --git a/tests/unit/workersComponents.test.mjs b/tests/unit/workersComponents.test.mjs index 4a5a8a2..3fa4c75 100644 --- a/tests/unit/workersComponents.test.mjs +++ b/tests/unit/workersComponents.test.mjs @@ -82,3 +82,47 @@ test('Karte und Board: Auswahl setzt den Fokus auf den Namen und holt die Karte assert.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/) assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/) }) + +// ---- P3-T21: Abmelden setzt das JWT zurück, „Alles anhalten“ fragt vorher nach ---- + +const readSrc = (p) => readFileSync(new URL('../../src/' + p, import.meta.url), 'utf8') + +test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => { + const auth = readSrc('context/AuthContext.jsx') + assert.match(auth, /import \{ resetWorkersJwt \} from '\.\.\/lib\/workersClient'/) + const start = auth.indexOf('async function logout()') + assert.ok(start > 0) + const logout = auth.slice(start, auth.indexOf('const isAdmin', start)) + assert.match(logout, /resetWorkersJwt\(\)/) + // im finally: auch ein fehlgeschlagenes deleteSessions lässt das JWT des alten Nutzers nicht im Cache + assert.ok(logout.indexOf('finally') > 0 && logout.indexOf('resetWorkersJwt()') > logout.indexOf('finally')) + const client = readSrc('lib/workersClient.js') + assert.match(client, /export function resetWorkersJwt\(\)/) + const fn = client.slice(client.indexOf('export function resetWorkersJwt()')) + assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/) +}) + +test('WorkersBoard: „Alles anhalten“ öffnet erst einen Bestätigungsdialog, „Fortsetzen“ geht sofort (P3-T21)', () => { + const s = read('WorkersBoard.jsx') + assert.match(s, /import Dialog from '\.\.\/ui\/Dialog'/) + assert.match(s, /]*title="Alles anhalten\?"/) + assert.ok(s.includes('Die Worker hören sofort auf, bis jemand fortsetzt.')) + // beide Knöpfe des Dialogs: type="button", Beschriftung „Anhalten“ und „Abbrechen“ + assert.match(s, /