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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:36:00 +00:00
1 parent c6c022678f
commit 777d5272bf
6 files changed
+279 -19

No files matched your search

+154 -2
View File
@@ -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
})
+44
View File
@@ -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, /<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\(/)
})