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, /