diff --git a/package.json b/package.json index 4dab861..79b1a3e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/build.mjs --check", + "test": "node --test worker/test/*.test.js", "serve": "python3 -m http.server 8000" }, "devDependencies": { diff --git a/worker/test/index.test.js b/worker/test/index.test.js new file mode 100644 index 0000000..65cca6f --- /dev/null +++ b/worker/test/index.test.js @@ -0,0 +1,159 @@ +// Run with: npm test (node:test, no dependencies) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import worker from '../src/index.js'; + +const ENDPOINT = 'https://www.devilsrock.de/api/schaden-melden'; + +function makeEnv({ limiterSuccess = true, sendError = null } = {}) { + const sent = []; + return { + sent, + ALLOWED_ORIGINS: 'https://www.devilsrock.de,https://devilsrock.de', + REPORT_TO: 'to@example.com', + REPORT_FROM: 'from@example.com', + REPORT_LIMITER: { limit: async () => ({ success: limiterSuccess }) }, + EMAIL: { + send: async message => { + if (sendError) throw sendError; + sent.push(message); + }, + }, + }; +} + +function validForm(overrides = {}) { + const form = new FormData(); + const values = { + name: 'Max', + contact: 'max@example.com', + location: 'Abschnitt 3', + description: 'Baum liegt quer', + ...overrides, + }; + for (const [key, value] of Object.entries(values)) form.set(key, value); + return form; +} + +function post(form, headers = {}) { + return new Request(ENDPOINT, { + method: 'POST', + body: form, + headers: { Accept: 'application/json', ...headers }, + }); +} + +test('unknown path returns 404', async () => { + const res = await worker.fetch(new Request('https://www.devilsrock.de/other'), makeEnv()); + assert.equal(res.status, 404); +}); + +test('non-POST returns 405 with Allow header', async () => { + const res = await worker.fetch(new Request(ENDPOINT), makeEnv()); + assert.equal(res.status, 405); + assert.equal(res.headers.get('Allow'), 'POST'); +}); + +test('foreign origin is rejected', async () => { + const env = makeEnv(); + const res = await worker.fetch(post(validForm(), { Origin: 'https://evil.example' }), env); + assert.equal(res.status, 403); + assert.equal(env.sent.length, 0); +}); + +test('rate limited request returns 429', async () => { + const env = makeEnv({ limiterSuccess: false }); + const res = await worker.fetch(post(validForm()), env); + assert.equal(res.status, 429); + assert.equal(env.sent.length, 0); +}); + +test('missing required field returns 400', async () => { + const env = makeEnv(); + const res = await worker.fetch(post(validForm({ location: ' ' })), env); + assert.equal(res.status, 400); + assert.equal((await res.json()).ok, false); + assert.equal(env.sent.length, 0); +}); + +test('honeypot reports success without sending mail', async () => { + const env = makeEnv(); + const res = await worker.fetch(post(validForm({ website: 'http://spam' })), env); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + assert.equal(env.sent.length, 0); +}); + +test('valid report is emailed with reply-to and escaped HTML', async () => { + const env = makeEnv(); + const res = await worker.fetch( + post(validForm({ description: '', location: 'A\r\nBcc: x' }), { + Origin: 'https://www.devilsrock.de', + }), + env, + ); + assert.equal(res.status, 200); + assert.equal(env.sent.length, 1); + const mail = env.sent[0]; + assert.equal(mail.to, 'to@example.com'); + assert.equal(mail.replyTo, 'max@example.com'); + assert.doesNotMatch(mail.subject, /[\r\n]/); + assert.match(mail.html, /<script>/); + assert.doesNotMatch(mail.html, /