import { test, before, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import express from 'express' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' let fake let server let baseUrl let createPushTimeToken let resetPushTimeRateLimitForTests let isRateLimited let rateLimitSize const gitSheet = (id, extra = {}) => ({ $id: id, wsid: '100300', woid: '100118', workorderId: 't1', serviceType: 'GIT', employeeName: 'knso', endDate: '30.09.2026', endTime: '1646', startDate: '30.09.2026', startTime: '', totalTime: 0, isComment: true, details: 'Git Push auf `WEBklar/amin-friseur` (Branch: main)\nCommit: abc1234\n\n- [abc1234](https://git/x): Startseite neu (knso)', ...extra, }) before(async () => { fake = await startFakeAppwrite({ seed: { workorders: [{ $id: 't1', woid: '100118', customerName: 'Amin Friseur' }], worksheets: [ gitSheet('open1'), gitSheet('open2'), gitSheet('open3'), gitSheet('done1', { startTime: '1500', totalTime: 45, isComment: false }), gitSheet('evil', { details: 'Git Push auf `X` (Branch: main)\n\n- [abc1234](u): fett' }), { $id: 'remote1', serviceType: 'Remote', startTime: '', totalTime: 0 }, ], }, }) Object.assign(process.env, { APPWRITE_ENDPOINT: fake.endpoint, APPWRITE_API_KEY: 'test-key', PUSH_TIME_SECRET: 'r'.repeat(40), }) const routes = await import('../server/routes/pushTime.js') resetPushTimeRateLimitForTests = routes.resetPushTimeRateLimitForTests isRateLimited = routes.isRateLimited rateLimitSize = routes.pushTimeRateLimitSizeForTests ;({ createPushTimeToken } = await import('../server/services/pushTimeToken.js')) const app = express() app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), routes.default) server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)) }) baseUrl = `http://127.0.0.1:${server.address().port}` }) after(async () => { if (server) await new Promise((resolve) => server.close(resolve)) await fake.close() }) beforeEach(() => resetPushTimeRateLimitForTests?.()) const token = (worksheetId, now) => createPushTimeToken({ worksheetId, employeeId: 'k' }, now) async function get(path) { const res = await fetch(`${baseUrl}${path}`) return { status: res.status, html: await res.text(), headers: res.headers } } async function post(path, form) { const res = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString(), }) return { status: res.status, html: await res.text(), headers: res.headers } } test('GET speichert nie', async () => { const t = token('open1') for (const m of ['15', '30', '45', '60', '90', '120', '180', '']) { const r = await get(`/zeit/${t}${m ? `?min=${m}` : ''}`) assert.equal(r.status, 200) assert.match(r.html, /#100118/) assert.match(r.html, /Amin Friseur/) assert.match(r.html, /WEBklar\/amin-friseur/) assert.match(r.html, /Startseite neu/) } const ws = fake.get('worksheets', 'open1') assert.equal(ws.totalTime, 0) assert.equal(ws.startTime, '') }) test('GET mit min=60 füllt das Feld vor, Unsinn nicht', async () => { const t = token('open1') assert.match((await get(`/zeit/${t}?min=60`)).html, /name="minutes"[^>]*value="60"/) assert.match((await get(`/zeit/${t}?min=abc`)).html, /name="minutes"[^>]*value=""/) }) test('POST speichert', async () => { const r = await post(`/zeit/${token('open2')}`, { minutes: '45' }) assert.equal(r.status, 200) assert.match(r.html, /Eingetragen: 45 Min auf #100118/) const ws = fake.get('worksheets', 'open2') assert.equal(ws.totalTime, 45) assert.equal(ws.isComment, false) assert.equal(ws.startTime, '1601') }) test('POST mit ungültigen Minuten', async () => { for (const minutes of ['0', '721', '1.5', '']) { const r = await post(`/zeit/${token('open3')}`, { minutes }) assert.equal(r.status, 400, minutes) assert.match(r.html, /Bitte ganze Minuten zwischen 1 und 720 eingeben\./) assert.match(r.html, /name="minutes"/) } assert.equal(fake.get('worksheets', 'open3').totalTime, 0) }) test('schon eingetragen: nichts wird überschrieben', async () => { const t = token('done1') const g = await get(`/zeit/${t}?min=30`) assert.equal(g.status, 200) assert.match(g.html, /Schon eingetragen: 45 Min/) assert.doesNotMatch(g.html, /name="minutes"/) const p = await post(`/zeit/${t}`, { minutes: '30' }) assert.match(p.html, /Schon eingetragen: 45 Min/) assert.equal(fake.get('worksheets', 'done1').totalTime, 45) }) test('Token-Fehler und fehlende Blätter', async () => { assert.equal((await get('/zeit/muell')).status, 400) const old = token('open1', Date.now() - 15 * 86400 * 1000) const expired = await get(`/zeit/${old}`) assert.equal(expired.status, 410) assert.match(expired.html, /Link abgelaufen/) assert.equal((await post(`/zeit/${old}`, { minutes: '30' })).status, 410) const missing = await get(`/zeit/${token('gibtsnicht')}`) assert.equal(missing.status, 404) assert.match(missing.html, /Nicht mehr vorhanden/) assert.equal((await get(`/zeit/${token('remote1')}`)).status, 400) assert.equal((await post(`/zeit/${token('remote1')}`, { minutes: '30' })).status, 400) }) test('Escaping', async () => { const r = await get(`/zeit/${token('evil')}`) assert.match(r.html, /<b>fett<\/b>/) assert.doesNotMatch(r.html, /fett/) }) test('Sicherheits-Header und Ticket-Link', async () => { const r = await get(`/zeit/${token('open1')}`) assert.equal(r.headers.get('cache-control'), 'no-store') assert.equal(r.headers.get('x-robots-tag'), 'noindex') assert.equal(r.headers.get('referrer-policy'), 'no-referrer') assert.match(r.headers.get('content-type'), /text\/html; charset=utf-8/) assert.match(r.html, /https:\/\/ticket\.webklar\.com\/tickets\?woid=100118/) }) test('Rate-Limit: 31. Anfrage → 429', async () => { const t = token('open1') for (let i = 0; i < 30; i += 1) assert.equal((await get(`/zeit/${t}`)).status, 200) const r = await get(`/zeit/${t}`) assert.equal(r.status, 429) assert.match(r.html, /Zu viele Anfragen/) }) test('Rate-Limit-Speicher bleibt begrenzt (Review-Fix)', () => { const now = Date.now() for (let i = 0; i < 12000; i += 1) isRateLimited(`10.0.${Math.floor(i / 250)}.${i % 250}`, now) assert.ok(rateLimitSize() <= 10000, `Größe ${rateLimitSize()}`) // nach Ablauf des Fensters wird aufgeräumt isRateLimited('10.9.9.9', now + 16 * 60 * 1000) assert.equal(rateLimitSize(), 1) }) test('Appwrite-Störung: neutrale HTML-Seite statt „Nicht mehr vorhanden“ (Review-Fix)', async () => { const t = token('open1') fake.failNext({ method: 'GET', pathIncludes: '/documents/open1', status: 500 }) const r = await get(`/zeit/${t}`) assert.equal(r.status, 503) assert.match(r.headers.get('content-type'), /text\/html/) assert.match(r.html, /Bitte versuch es gleich noch einmal/) assert.doesNotMatch(r.html, /Nicht mehr vorhanden|fakeAppwrite/) })