feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026): - /vertrag-widerrufen: two-step flow "Vertrag widerrufen" -> "Widerruf bestätigen", no login, only name, order number and e-mail required. Prominent footer button on every page, prefilled link per order in the customer account. - /api/widerruf stores every declaration (matched to the order only if order number and e-mail fit), mails a receipt with the declaration and the exact time of receipt, notifies ADMIN_EMAIL. Same response for matching and non-matching orders (no enumeration), origin check and rate limit instead of a CAPTCHA that could block a statutory right. User input is HTML-escaped and stripped of line breaks in headers. - /admin/widerrufe lists declarations, open ones first, with an open-count badge and "mark as done". - Withdrawal notice gains the statutory sentence on the online function. Other fixes: - Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR). - "Recently viewed" is only persisted with consent (§ 25(1) TDDDG); choosing "essential only" or withdrawing consent deletes it. The banner no longer promises audience-measurement cookies that do not exist. - Admin rejects an invalid company e-mail; an obfuscated stored value falls back to the configured address so the legal notice stays valid. - Privacy policy: Microsoft 365 as mail processor, log retention of at most 14 days, withdrawal-function processing, consent timestamps. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
4ab1a7edae
commit
dcfad4e011
27 files changed
+1049
-34
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines, withdrawalReceiptEmailContent } from "./withdrawal";
|
||||
|
||||
const input = {
|
||||
name: "Erika Mustermann",
|
||||
orderNumber: "WH-2026-0042",
|
||||
email: "[email protected]",
|
||||
message: null,
|
||||
};
|
||||
|
||||
// 26.09.2026 12:03:07 UTC = 14:03:07 in Berlin (Sommerzeit)
|
||||
const receivedAt = new Date(Date.UTC(2026, 8, 26, 12, 3, 7));
|
||||
|
||||
describe("formatReceivedAt", () => {
|
||||
it("formats in German local time with seconds", () => {
|
||||
expect(formatReceivedAt(receivedAt, "de")).toBe("26.09.2026, 14:03:07 Uhr (MESZ)");
|
||||
});
|
||||
|
||||
it("uses winter time in winter", () => {
|
||||
expect(formatReceivedAt(new Date(Date.UTC(2026, 0, 5, 9, 0, 0)), "de")).toBe("05.01.2026, 10:00:00 Uhr (MEZ)");
|
||||
});
|
||||
|
||||
it("formats in English with the German time zone", () => {
|
||||
expect(formatReceivedAt(receivedAt, "en")).toBe("26 September 2026, 14:03:07 (CEST, German time)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withdrawalDeclarationLines", () => {
|
||||
it("states the withdrawal and the identifying data", () => {
|
||||
expect(withdrawalDeclarationLines(input, "de")).toEqual([
|
||||
"Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.",
|
||||
"Name: Erika Mustermann",
|
||||
"E-Mail: [email protected]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds an optional message", () => {
|
||||
expect(withdrawalDeclarationLines({ ...input, message: "Falsche Sorte" }, "en")).toEqual([
|
||||
"I hereby withdraw from the contract for order WH-2026-0042.",
|
||||
"Name: Erika Mustermann",
|
||||
"Email: [email protected]",
|
||||
"Message: Falsche Sorte",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("neutralises markup", () => {
|
||||
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe("<a href="x">'&'</a>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withdrawalReceiptEmailContent", () => {
|
||||
it("contains the declaration and the time of receipt in text and HTML", () => {
|
||||
const mail = withdrawalReceiptEmailContent(input, receivedAt, "de");
|
||||
expect(mail.subject).toBe("Eingangsbestätigung deines Widerrufs zu WH-2026-0042");
|
||||
expect(mail.text).toContain("Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.");
|
||||
expect(mail.text).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
|
||||
expect(mail.bodyHtml).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
|
||||
});
|
||||
|
||||
it("escapes user input in the HTML body and strips line breaks from the subject", () => {
|
||||
const mail = withdrawalReceiptEmailContent(
|
||||
{ ...input, name: `<img src=x onerror=alert(1)>`, orderNumber: "A1\r\nBcc: [email protected]" },
|
||||
receivedAt,
|
||||
"de"
|
||||
);
|
||||
expect(mail.bodyHtml).not.toContain("<img");
|
||||
expect(mail.bodyHtml).toContain("<img src=x onerror=alert(1)>");
|
||||
expect(mail.subject).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user