Workers P3: Nachträge aus der P2-Endprüfung (Kennungen, JWT-Sperre, Abmelden, Bestätigung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c6c022678f
commit
777d5272bf
6 files changed
+279
-19
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
|
import Dialog from '../ui/Dialog'
|
||||||
import EmptyState from '../ui/EmptyState'
|
import EmptyState from '../ui/EmptyState'
|
||||||
import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed'
|
import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed'
|
||||||
import { indexWorkers, reduceEvents } from '../../lib/workersEvents'
|
import { indexWorkers, reduceEvents } from '../../lib/workersEvents'
|
||||||
@@ -34,6 +35,7 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [pausing, setPausing] = useState(false)
|
const [pausing, setPausing] = useState(false)
|
||||||
const [pauseError, setPauseError] = useState('')
|
const [pauseError, setPauseError] = useState('')
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
@@ -53,6 +55,12 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
|
|||||||
|
|
||||||
const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId)
|
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 (!loaded || !state) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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
|
// Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert (true = anhalten), danach wird der Zustand sofort neu geholt.
|
||||||
const togglePause = async () => {
|
// 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
|
if (pausing) return
|
||||||
setPausing(true)
|
setPausing(true)
|
||||||
setPauseError('')
|
setPauseError('')
|
||||||
try {
|
try {
|
||||||
await api.pause(!paused)
|
await api.pause(target)
|
||||||
refresh()
|
refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
|
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)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="wk-toolbar">
|
<div className="wk-toolbar">
|
||||||
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
|
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
|
||||||
<span className="grow" />
|
<span className="grow" />
|
||||||
<Button type="button" onClick={togglePause} disabled={pausing}>{pauseLabel(paused)}</Button>
|
<Button type="button" onClick={requestPause} disabled={pausing}>{pauseLabel(paused)}</Button>
|
||||||
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
|
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,6 +153,22 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {
|
|||||||
{isAdmin && settingsOpen && (
|
{isAdmin && settingsOpen && (
|
||||||
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
|
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmOpen && !paused && (
|
||||||
|
<Dialog
|
||||||
|
title="Alles anhalten?"
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="grow" />
|
||||||
|
<Button type="button" onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button variant="primary" type="button" onClick={confirmHalt}>Anhalten</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>Die Worker hören sofort auf, bis jemand fortsetzt.</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../lib/appwrite'
|
} from '../lib/appwrite'
|
||||||
import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa'
|
import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa'
|
||||||
import { isStaffAccount, NO_STAFF_ACCESS } from '../lib/staffAccess'
|
import { isStaffAccount, NO_STAFF_ACCESS } from '../lib/staffAccess'
|
||||||
|
import { resetWorkersJwt } from '../lib/workersClient'
|
||||||
|
|
||||||
const AuthContext = createContext()
|
const AuthContext = createContext()
|
||||||
const mfa = createMfaApi(client)
|
const mfa = createMfaApi(client)
|
||||||
@@ -238,6 +239,7 @@ export function AuthProvider({ children }) {
|
|||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
clearAllLocalAppwriteSessions()
|
clearAllLocalAppwriteSessions()
|
||||||
|
resetWorkersJwt() // JWT und Sperre der Hustler-Seite gehören zur beendeten Sitzung
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setEmployee(null)
|
setEmployee(null)
|
||||||
setMfaPending(null)
|
setMfaPending(null)
|
||||||
|
|||||||
+32
-13
@@ -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 BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.'
|
||||||
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
||||||
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
|
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,
|
// 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.
|
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
|
||||||
export const REQUEST_TIMEOUT_MS = 20000
|
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
|
// 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
|
// erzwungen, und scheitert createJWT selbst (z. B. Appwrite 429), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt
|
||||||
// und damit auch Serverpuls, Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab.
|
// 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
|
export const AUTH_COOLDOWN_MS = 120000
|
||||||
|
|
||||||
// Pult-Status des Dienstes (Übersicht §4.11)
|
// 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 })
|
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
|
// Query aus Paaren; leere Werte (undefined, null, '') fallen weg, der Rest wird kodiert
|
||||||
function query(pairs) {
|
function query(pairs) {
|
||||||
const parts = []
|
const parts = []
|
||||||
@@ -61,7 +70,8 @@ export function createWorkersApi({
|
|||||||
authCooldownMs = AUTH_COOLDOWN_MS,
|
authCooldownMs = AUTH_COOLDOWN_MS,
|
||||||
now = Date.now,
|
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
|
// 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).
|
// 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) {
|
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 {
|
try {
|
||||||
return await getJwt(options)
|
return await getJwt(options)
|
||||||
} catch {
|
} 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)
|
throw statusError(SESSION_TEXT, 401)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,13 +148,9 @@ export function createWorkersApi({
|
|||||||
if (out.unauthorized) {
|
if (out.unauthorized) {
|
||||||
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
|
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
|
||||||
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
|
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
|
||||||
// sonst einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen und die Sperre beginnt
|
// sonst einmal mit frischem JWT wiederholen (lässt es sich nicht erzeugen, beginnt die Sperre in jwtFor), danach gilt die
|
||||||
try {
|
// Sitzung als abgelaufen und die Sperre beginnt
|
||||||
out = await attempt(path, method, body, { fresh: true })
|
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
|
|
||||||
}
|
|
||||||
if (out.unauthorized) {
|
if (out.unauthorized) {
|
||||||
freshBlockedUntil = now() + authCooldownMs
|
freshBlockedUntil = now() + authCooldownMs
|
||||||
throw statusError(SESSION_TEXT, 401)
|
throw statusError(SESSION_TEXT, 401)
|
||||||
@@ -148,7 +162,7 @@ export function createWorkersApi({
|
|||||||
return {
|
return {
|
||||||
state: () => request('/state'),
|
state: () => request('/state'),
|
||||||
events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`),
|
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) } }),
|
pause: (paused) => request('/pause', { method: 'POST', body: { paused: Boolean(paused) } }),
|
||||||
settings: () => request('/settings'),
|
settings: () => request('/settings'),
|
||||||
saveSettings: (patch) => request('/settings', { method: 'PUT', body: patch }),
|
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(',')}`),
|
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
|
||||||
pultAction: async (id, action, body) => {
|
pultAction: async (id, action, body) => {
|
||||||
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
|
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]])}`),
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,3 +12,10 @@ const getJwt = (options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const workersApi = createWorkersApi({ getJwt })
|
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()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { loadModule } from './load.mjs'
|
|||||||
|
|
||||||
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
|
// 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 { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS } = await loadModule('src/lib/workersApi.js')
|
||||||
|
const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.js')
|
||||||
|
|
||||||
const jwts = []
|
const jwts = []
|
||||||
const getJwt = async (o) => {
|
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 })
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
t += 10000
|
t += 10000
|
||||||
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
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
|
t += 60000
|
||||||
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
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 () => {
|
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(await api.state(), {}) // 401, Wiederholung frisch ⇒ 200
|
||||||
assert.deepEqual(jwts, [false, true, false, false, true])
|
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
|
||||||
|
})
|
||||||
@@ -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.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/)
|
||||||
assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/)
|
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, /<Dialog\b[^>]*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, /<Button type="button" onClick=\{[^}]*\}[^>]*>Abbrechen<\/Button>/)
|
||||||
|
assert.match(s, /<Button variant="primary" type="button" onClick=\{confirmHalt\}[^>]*>Anhalten<\/Button>/)
|
||||||
|
// Der Knopf der Leiste fragt nur beim Anhalten nach; Fortsetzen ruft den Dienst ohne Dialog
|
||||||
|
assert.match(s, /<Button type="button" onClick=\{requestPause\}/)
|
||||||
|
assert.match(s, /const requestPause = \(\) => \{\s*if \(paused\) setPause\(false\)\s*else setConfirmOpen\(true\)\s*\}/)
|
||||||
|
// Bestätigen schließt den Dialog und hält an; der Zielwert steht fest (nicht !paused: sonst setzte ein veralteter Dialog die Arbeit wieder fort)
|
||||||
|
assert.match(s, /const confirmHalt = \(\) => \{\s*setConfirmOpen\(false\)\s*setPause\(true\)\s*\}/)
|
||||||
|
assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/)
|
||||||
|
// der Dialog steht nur offen, solange nicht schon angehalten ist (hat jemand anderes angehalten, entfällt die Frage)
|
||||||
|
assert.match(s, /\{confirmOpen && !paused && \(/)
|
||||||
|
// und die Frage taucht nach einem späteren Fortsetzen nicht von selbst wieder auf
|
||||||
|
assert.match(s, /useEffect\(\(\) => \{\s*if \(haltedByState\) setConfirmOpen\(false\)\s*\}, \[haltedByState\]\)/)
|
||||||
|
// Hooks vor der frühen Rückgabe
|
||||||
|
assert.ok(s.indexOf('useState(false)') < s.indexOf('if (!loaded || !state)') && s.indexOf('const [confirmOpen') < s.indexOf('if (!loaded || !state)'))
|
||||||
|
// PF-08 gilt weiter: jeder Knopf ein type="button"
|
||||||
|
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
||||||
|
assert.doesNotMatch(s, /window\.confirm|confirm\(/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user