diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index 94538be..8652b46 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -24,6 +24,12 @@ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9]) * Variante B (direkt / Legacy): { domain: string, type: 'web' | 'mail_domain' | 'mail_display_name' } * - type='mail_display_name' → throws 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0) * + * Variante C (Auto-Detect): { pattern: string } ohne kind + * - kind fehlt oder kind='auto' → Auto-Detect anhand pattern-Format + * - pattern.includes('@') → wie kind='mail' (local-part strip, validate, mail_domain) + * - pattern.includes('.') && kein '@' → wie kind='web' (validate, web) + * - weder '@' noch '.' → 400 INVALID_PATTERN + * * Display-Name-Blocking kommt in v1.1 mit eigener UX. * * Returns null wenn ein 400-Error geworfen werden soll — caller wirft dann den Error @@ -31,14 +37,37 @@ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9]) */ type ResolveResult = | { ok: true; type: CustomDomainType; value: string } - | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" }; + | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" | "INVALID_PATTERN" }; function resolveTypeAndValue(body: any): ResolveResult { - // Variante A: pattern + kind + // Variante A + C: pattern vorhanden (mit oder ohne kind) if (body?.kind !== undefined || body?.pattern !== undefined) { - const kind = (body?.kind as string)?.trim() ?? "web"; + const rawKind = (body?.kind as string)?.trim(); const pattern = (body?.pattern as string)?.trim() ?? ""; + // Variante C: kind fehlt oder kind='auto' → Auto-Detect anhand pattern-Format + if (rawKind === undefined || rawKind === null || rawKind === "" || rawKind === "auto") { + if (pattern.includes("@")) { + // Wie kind='mail': local-part strippen, als mail_domain speichern + const atIdx = pattern.lastIndexOf("@"); + const normalized = pattern.slice(atIdx + 1).toLowerCase().replace(/^https?:\/\//, "").trim(); + if (normalized.includes(".") && DOMAIN_RE.test(normalized)) { + return { ok: true, type: "mail_domain", value: normalized }; + } + return { ok: false, error: "INVALID_MAIL_DOMAIN" }; + } + + if (pattern.includes(".")) { + // Wie kind='web' + return { ok: true, type: "web", value: pattern }; + } + + // Weder '@' noch '.' → kein erkennbares Format + return { ok: false, error: "INVALID_PATTERN" }; + } + + const kind = rawKind; + if (kind === "web") { return { ok: true, type: "web", value: pattern }; } @@ -104,6 +133,16 @@ export default defineEventHandler(async (event) => { }, }); } + if (resolved.error === "INVALID_PATTERN") { + throw createError({ + statusCode: 400, + data: { + error: "INVALID_PATTERN", + message: + "Bitte eine Domain (z.B. casino.com) oder Mail-Adresse (z.B. info@casino.com) eingeben.", + }, + }); + } } const { type, value: rawValue } = resolved as { ok: true; type: CustomDomainType; value: string }; diff --git a/backend/tests/custom-domains/plan-limits.test.ts b/backend/tests/custom-domains/plan-limits.test.ts index 6e65d69..b787a5b 100644 --- a/backend/tests/custom-domains/plan-limits.test.ts +++ b/backend/tests/custom-domains/plan-limits.test.ts @@ -62,15 +62,35 @@ describe("getPlanLimits — customDomains ist strukturiertes Objekt", () => { */ type TestResolveResult = | { ok: true; type: string; value: string } - | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" }; + | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" | "INVALID_PATTERN" }; function resolveTypeAndValueForTest(body: any): TestResolveResult { const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; if (body?.kind !== undefined || body?.pattern !== undefined) { - const kind = (body?.kind as string)?.trim() ?? "web"; + const rawKind = (body?.kind as string)?.trim(); const pattern = (body?.pattern as string)?.trim() ?? ""; + // Variante C: kind fehlt oder kind='auto' → Auto-Detect + if (rawKind === undefined || rawKind === null || rawKind === "" || rawKind === "auto") { + if (pattern.includes("@")) { + const atIdx = pattern.lastIndexOf("@"); + const normalized = pattern.slice(atIdx + 1).toLowerCase().replace(/^https?:\/\//, "").trim(); + if (normalized.includes(".") && DOMAIN_RE.test(normalized)) { + return { ok: true, type: "mail_domain", value: normalized }; + } + return { ok: false, error: "INVALID_MAIL_DOMAIN" }; + } + + if (pattern.includes(".")) { + return { ok: true, type: "web", value: pattern }; + } + + return { ok: false, error: "INVALID_PATTERN" }; + } + + const kind = rawKind; + if (kind === "web") { return { ok: true, type: "web", value: pattern }; } @@ -322,3 +342,77 @@ describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => { expect(isSubmittable).toBe(true); }); }); + +// ─── Variante C — Auto-Detect (kein kind im Body) ──────────────────────────── + +describe("Auto-Detect — { pattern } ohne kind (Variante C)", () => { + it("{ pattern: 'casino.com' } → type='web', value='casino.com'", () => { + const r = resolveTypeAndValueForTest({ pattern: "casino.com" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("web"); + expect(r.value).toBe("casino.com"); + } + }); + + it("{ pattern: 'info@casino.com' } → type='mail_domain', value='casino.com'", () => { + const r = resolveTypeAndValueForTest({ pattern: "info@casino.com" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("casino.com"); + } + }); + + it("{ pattern: 'INVALID' } → 400 INVALID_PATTERN (kein '@', kein '.')", () => { + const r = resolveTypeAndValueForTest({ pattern: "INVALID" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("INVALID_PATTERN"); + }); + + it("{ pattern: 'a.b@c.d' } → type='mail_domain', value='c.d' (lastIndexOf @ strip)", () => { + const r = resolveTypeAndValueForTest({ pattern: "a.b@c.d" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("c.d"); + } + }); + + it("{ kind: 'auto', pattern: 'casino.com' } → type='web' (kind='auto' triggert Auto-Detect)", () => { + const r = resolveTypeAndValueForTest({ kind: "auto", pattern: "casino.com" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("web"); + expect(r.value).toBe("casino.com"); + } + }); + + it("{ kind: 'auto', pattern: 'no@valid.domain.here' } → type='mail_domain', value='valid.domain.here'", () => { + const r = resolveTypeAndValueForTest({ kind: "auto", pattern: "no@valid.domain.here" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("valid.domain.here"); + } + }); + + // Bestehende Variante A bleibt unberührt (Backwards-Compat) + it("Backwards-Compat: { kind: 'web', pattern: 'casino.de' } → type='web' (weiterhin)", () => { + const r = resolveTypeAndValueForTest({ kind: "web", pattern: "casino.de" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("web"); + expect(r.value).toBe("casino.de"); + } + }); + + it("Backwards-Compat: { kind: 'mail', pattern: 'news@bet365.de' } → type='mail_domain', value='bet365.de'", () => { + const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "news@bet365.de" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("bet365.de"); + } + }); +});