Push-Zeitmail: Bestätigungsseite /zeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a1005d099
commit
a28ae21389
3 files changed
+381
No files matched your search
@@ -16,6 +16,7 @@ import takeoverCreditRoutes from './routes/takeoverCredits.js'
|
|||||||
import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||||
|
import pushTimeRoutes from './routes/pushTime.js'
|
||||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||||
import adminCustomersRoutes from './routes/admin/customers.js'
|
import adminCustomersRoutes from './routes/admin/customers.js'
|
||||||
@@ -68,6 +69,8 @@ app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
|||||||
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
||||||
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
||||||
app.use('/webhook', giteaWebhookRoutes)
|
app.use('/webhook', giteaWebhookRoutes)
|
||||||
|
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
|
||||||
|
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
const dbAccess = await verifyDatabaseAccess()
|
const dbAccess = await verifyDatabaseAccess()
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { getDocument } from '../services/appwriteAdmin.js'
|
||||||
|
import { escapeHtml } from '../services/mailTemplates.js'
|
||||||
|
import { verifyPushTimeToken } from '../services/pushTimeToken.js'
|
||||||
|
import { applyPushTime, formatMinutes, needsTimeEntry, parseMinutes, MAX_MINUTES } from '../services/pushTimeEntry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestaetigungsseite der Push-Zeitmail: /zeit/<token>[?min=60].
|
||||||
|
* GET zeigt nur an (Mail-Scanner und Link-Vorschauen oeffnen Links automatisch), erst POST speichert.
|
||||||
|
* Kein Login: der signierte Token erlaubt nur, die Zeit genau dieses Git-Arbeitsblatts einzutragen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// Einfaches Rate-Limit je IP (30 Anfragen / 15 Minuten)
|
||||||
|
const RATE_LIMIT = 30
|
||||||
|
const RATE_WINDOW_MS = 15 * 60 * 1000
|
||||||
|
const hits = new Map()
|
||||||
|
|
||||||
|
export function resetPushTimeRateLimitForTests() {
|
||||||
|
hits.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimited(ip) {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of hits) if (entry.resetAt <= now) hits.delete(key)
|
||||||
|
const entry = hits.get(ip) || { count: 0, resetAt: now + RATE_WINDOW_MS }
|
||||||
|
entry.count += 1
|
||||||
|
hits.set(ip, entry)
|
||||||
|
return entry.count > RATE_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = (value) => escapeHtml(value ?? '')
|
||||||
|
|
||||||
|
function page(res, status, title, inner) {
|
||||||
|
res.status(status)
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Robots-Tag': 'noindex',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
})
|
||||||
|
res.send(`<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>${e(title)} · WEBklar</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:#f1f0ec;color:#111;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5}
|
||||||
|
main{max-width:520px;margin:0 auto;padding:32px 16px}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;font-size:22px;font-weight:bold;letter-spacing:-1px;margin-bottom:20px}
|
||||||
|
.brand img{width:36px;height:36px;border-radius:9px}
|
||||||
|
.card{background:#fff;border-radius:16px;padding:28px 24px}
|
||||||
|
h1{font-size:24px;line-height:1.25;margin:0 0 8px;letter-spacing:-.3px}
|
||||||
|
.mut{color:#6b6964}
|
||||||
|
dl{margin:18px 0 0;font-size:14px}
|
||||||
|
dt{color:#6b6964;margin-top:8px}
|
||||||
|
dd{margin:0}
|
||||||
|
ul{margin:4px 0 0;padding-left:18px;font-family:Menlo,Consolas,monospace;font-size:13px}
|
||||||
|
label{display:block;font-weight:bold;margin:22px 0 6px}
|
||||||
|
input[type=number]{width:100%;font-size:20px;padding:12px;border:1px solid #d9d6ce;border-radius:10px}
|
||||||
|
.err{background:#fdecec;color:#8a1c1c;border-radius:10px;padding:10px 12px;margin-top:16px;font-size:14px}
|
||||||
|
.ok{background:#e6f4ea;color:#1d5b2c;border-radius:10px;padding:10px 12px;margin-top:4px}
|
||||||
|
button,.btn{display:inline-block;margin-top:16px;padding:13px 22px;border-radius:10px;font-size:16px;font-weight:bold;text-decoration:none;cursor:pointer}
|
||||||
|
button{background:#111;color:#fff;border:0;width:100%}
|
||||||
|
.btn{background:#fff;color:#111;border:1px solid #d9d6ce}
|
||||||
|
@media (prefers-color-scheme:dark){body{background:#161615;color:#f2f1ee}.card{background:#1f1f1e}input[type=number]{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}button{background:#f2f1ee;color:#161615}.btn{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}.mut,dt{color:#a8a6a0}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<div class="brand"><img src="/webklar-logo.png" alt="">WEBklar</div>
|
||||||
|
<div class="card">${inner}</div>
|
||||||
|
</main></body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const womsHome = () => `${config.womsBaseUrl}/tickets`
|
||||||
|
const ticketLink = (woid) => `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid || '')}`
|
||||||
|
const openTicket = (woid) => `<a class="btn" href="${e(ticketLink(woid))}">Ticket öffnen →</a>`
|
||||||
|
|
||||||
|
function errorPage(res, status, title, text) {
|
||||||
|
page(res, status, title, `<h1>${e(title)}</h1><p class="mut">${e(text)}</p><a class="btn" href="${e(womsHome())}">Zum Ticketsystem →</a>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repo und Commit-Titel aus den Arbeitsblatt-Details (Format von gitWorksheet.js) */
|
||||||
|
function pushInfo(details) {
|
||||||
|
const lines = String(details || '').split('\n')
|
||||||
|
const repo = lines[0]?.match(/`([^`]+)`/)?.[1] || ''
|
||||||
|
const commits = lines
|
||||||
|
.map((line) => line.match(/^- \[([0-9a-f]{7})\]\([^)]*\): (.*?)(?: \([^()]*\))?$/i))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((m) => `${m[1]} ${m[2]}`)
|
||||||
|
return { repo, commits }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTicket(ws) {
|
||||||
|
try {
|
||||||
|
return await getDocument(config.collections.workorders, ws.workorderId)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formPage(res, status, { ws, ticket, token, minutes, error }) {
|
||||||
|
const { repo, commits } = pushInfo(ws.details)
|
||||||
|
const woid = ticket?.woid || ws.woid
|
||||||
|
const endTime = String(ws.endTime || '')
|
||||||
|
const pushTime = `${ws.endDate || ''}${endTime ? ` ${endTime.slice(0, 2)}:${endTime.slice(2, 4)}` : ''}`
|
||||||
|
page(
|
||||||
|
res,
|
||||||
|
status,
|
||||||
|
'Zeit eintragen',
|
||||||
|
`<h1>Zeit eintragen</h1>
|
||||||
|
<p class="mut">Für deinen Push auf Ticket #${e(woid)}${ticket?.customerName ? ` · ${e(ticket.customerName)}` : ''}</p>
|
||||||
|
<dl>
|
||||||
|
${repo ? `<dt>Repository</dt><dd>${e(repo)}</dd>` : ''}
|
||||||
|
<dt>Push</dt><dd>${e(pushTime)}</dd>
|
||||||
|
${commits.length ? `<dt>Commits</dt><dd><ul>${commits.map((c) => `<li>${e(c)}</li>`).join('')}</ul></dd>` : ''}
|
||||||
|
</dl>
|
||||||
|
${error ? `<div class="err">${e(error)}</div>` : ''}
|
||||||
|
<form method="post" action="/zeit/${e(token)}">
|
||||||
|
<label for="minutes">Minuten</label>
|
||||||
|
<input id="minutes" type="number" name="minutes" min="1" max="${MAX_MINUTES}" step="1" inputmode="numeric" value="${e(minutes ?? '')}" required autofocus>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
${openTicket(woid)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function alreadyPage(res, ws, ticket) {
|
||||||
|
const woid = ticket?.woid || ws.woid
|
||||||
|
page(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
'Schon eingetragen',
|
||||||
|
`<h1>Schon eingetragen: ${e(formatMinutes(ws.totalTime))}</h1><p class="mut">Die Zeit für diesen Push auf #${e(woid)} ist bereits erfasst. Ändern kannst du sie im Ticketsystem.</p>${openTicket(woid)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token pruefen und Arbeitsblatt laden; schickt bei Fehlern selbst die Seite und liefert null. */
|
||||||
|
async function resolve(req, res) {
|
||||||
|
if (rateLimited(req.ip || 'unbekannt')) {
|
||||||
|
errorPage(res, 429, 'Zu viele Anfragen', 'Bitte versuch es in ein paar Minuten noch einmal.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const check = verifyPushTimeToken(req.params.token)
|
||||||
|
if (check.error === 'expired') {
|
||||||
|
errorPage(res, 410, 'Link abgelaufen', 'Der Link aus der Mail gilt 14 Tage. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (check.error) {
|
||||||
|
errorPage(res, 400, 'Link ungültig', 'Dieser Link ist nicht gültig. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let ws
|
||||||
|
try {
|
||||||
|
ws = await getDocument(config.collections.worksheets, check.worksheetId)
|
||||||
|
} catch {
|
||||||
|
errorPage(res, 404, 'Nicht mehr vorhanden', 'Dieses Arbeitsblatt gibt es nicht mehr.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (ws.serviceType !== 'GIT') {
|
||||||
|
errorPage(res, 400, 'Link ungültig', 'Dieser Link ist nicht gültig. Trag die Zeit bitte direkt im Ticketsystem ein.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { ws, ticket: await loadTicket(ws) }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:token', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const found = await resolve(req, res)
|
||||||
|
if (!found) return
|
||||||
|
if (!needsTimeEntry(found.ws)) return alreadyPage(res, found.ws, found.ticket)
|
||||||
|
formPage(res, 200, { ...found, token: req.params.token, minutes: parseMinutes(req.query.min) ?? '' })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/:token', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const found = await resolve(req, res)
|
||||||
|
if (!found) return
|
||||||
|
if (!needsTimeEntry(found.ws)) return alreadyPage(res, found.ws, found.ticket)
|
||||||
|
|
||||||
|
const minutes = parseMinutes(req.body?.minutes)
|
||||||
|
if (minutes === null) {
|
||||||
|
return formPage(res, 400, {
|
||||||
|
...found,
|
||||||
|
token: req.params.token,
|
||||||
|
minutes: '',
|
||||||
|
error: `Bitte ganze Minuten zwischen 1 und ${MAX_MINUTES} eingeben.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await applyPushTime(found.ws.$id, minutes)
|
||||||
|
if (result.status === 'already') return alreadyPage(res, result.worksheet, found.ticket)
|
||||||
|
if (result.status !== 'saved') {
|
||||||
|
return errorPage(res, 404, 'Nicht mehr vorhanden', 'Dieses Arbeitsblatt gibt es nicht mehr.')
|
||||||
|
}
|
||||||
|
const woid = found.ticket?.woid || found.ws.woid
|
||||||
|
page(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
'Eingetragen',
|
||||||
|
`<h1>Danke!</h1><p class="ok">Eingetragen: ${e(formatMinutes(minutes))} auf #${e(woid)}</p>${openTicket(woid)}`
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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): <b>fett</b>' }),
|
||||||
|
{ $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
|
||||||
|
;({ 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, /<b>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/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user