diff --git a/src/lib/datenschutzZiel.ts b/src/lib/datenschutzZiel.ts
new file mode 100644
index 0000000..5ab5e78
--- /dev/null
+++ b/src/lib/datenschutzZiel.ts
@@ -0,0 +1,6 @@
+// Die Datenschutzerklärung liegt zentral im Kundenportal. Ein Anker aus der Adresse
+// (zum Beispiel #interessenten aus der Kaltmail) wird nur übernommen, wenn er
+// ausschließlich aus Buchstaben, Ziffern, Unterstrich und Bindestrich besteht.
+const BASIS = "https://project.webklar.com/datenschutz.html";
+
+export const weiterleitungsZiel = (hash: string): string => (/^#[A-Za-z0-9_-]{1,64}$/.test(hash) ? BASIS + hash : BASIS);
diff --git a/src/pages/Datenschutz.tsx b/src/pages/Datenschutz.tsx
index 96b699f..18cdc88 100644
--- a/src/pages/Datenschutz.tsx
+++ b/src/pages/Datenschutz.tsx
@@ -1,17 +1,17 @@
import { useEffect } from "react";
+import { weiterleitungsZiel } from "@/lib/datenschutzZiel";
// Die Datenschutzerklärung gilt für webklar.com und das Kundenportal gemeinsam
-// und liegt zentral im Kundenportal.
-const URL = "https://project.webklar.com/datenschutz.html";
-
+// und liegt zentral im Kundenportal. Ein Anker (#interessenten) geht dorthin mit.
const Datenschutz = () => {
+ const ziel = weiterleitungsZiel(window.location.hash);
useEffect(() => {
- window.location.replace(URL);
- }, []);
+ window.location.replace(ziel);
+ }, [ziel]);
return (
- Weiter zur Datenschutzerklärung …
+ Weiter zur Datenschutzerklärung …
);
diff --git a/src/test/datenschutz-weiterleitung.test.tsx b/src/test/datenschutz-weiterleitung.test.tsx
new file mode 100644
index 0000000..7945b14
--- /dev/null
+++ b/src/test/datenschutz-weiterleitung.test.tsx
@@ -0,0 +1,37 @@
+import { afterEach, describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import Datenschutz from "@/pages/Datenschutz";
+import { weiterleitungsZiel } from "@/lib/datenschutzZiel";
+
+const BASIS = "https://project.webklar.com/datenschutz.html";
+
+describe("Weiterleitung /datenschutz", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ window.history.pushState({}, "", "/");
+ });
+
+ it("nimmt einen gültigen Anker mit", () => {
+ expect(weiterleitungsZiel("#interessenten")).toBe(`${BASIS}#interessenten`);
+ expect(weiterleitungsZiel("#chat-anhaenge")).toBe(`${BASIS}#chat-anhaenge`);
+ });
+ it("ohne Anker oder mit ungültigem Anker bleibt es bei der Seite", () => {
+ for (const hash of ["", "#", "#javascript:alert(1)", '#a"b', "#a b", `#${"a".repeat(65)}`]) {
+ expect(weiterleitungsZiel(hash)).toBe(BASIS);
+ }
+ });
+ it("der Link im Text trägt denselben Anker", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {}); // jsdom meldet die nicht umgesetzte Navigation
+ window.history.pushState({}, "", "/datenschutz#interessenten");
+ render();
+ expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", `${BASIS}#interessenten`);
+ });
+ it("leitet mit dem Anker auf die Datenschutzerklärung im Portal weiter", () => {
+ window.history.pushState({}, "", "/datenschutz#interessenten");
+ const replace = vi.fn();
+ vi.stubGlobal("location", { hash: window.location.hash, replace });
+ render();
+ expect(replace).toHaveBeenCalledExactlyOnceWith(`${BASIS}#interessenten`);
+ });
+});