feat: DOI-Anmeldung, Bestätigung und Löschungs-API (Findings 3+4)

- POST /api/subscribers: Anmeldung mit Consent-IP/UA, Antwort ohne E-Mail
- POST /api/subscribers/confirm: Token-Bestätigung setzt status=active
- DELETE /api/subscribers/[id]: DSGVO Art. 17 Löschrecht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 17:51:25 +00:00
parent 4174b33016
commit 0a81894283
4 changed files with 248 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { deleteSubscriber } from '../../../../server/db/subscribers'
import { getTenantId } from '../../../../lib/tenant-header'
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const result = await deleteSubscriber(tenantId, params.id)
if (!result.ok) {
const status = result.error.message.includes('nicht gefunden') ? 404 : 500
return NextResponse.json({ error: result.error.message }, { status })
}
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { confirmDoi } from '../../../../server/db/subscribers'
import { getTenantId } from '../../../../lib/tenant-header'
const ConfirmSchema = z.object({ token: z.string().uuid() })
export async function POST(req: NextRequest) {
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const body = await req.json() as unknown
const parsed = ConfirmSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
const result = await confirmDoi(tenantId, parsed.data.token)
if (!result.ok) return NextResponse.json({ error: 'Ungültiger oder abgelaufener Link' }, { status: 400 })
return NextResponse.json({ status: 'active' })
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createSubscriber } from '../../../server/db/subscribers'
import { getTenantId } from '../../../lib/tenant-header'
const SubscribeSchema = z.object({
email: z.string().email(),
listId: z.string().uuid().optional(),
})
export async function POST(req: NextRequest) {
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const body = await req.json() as unknown
const parsed = SubscribeSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
const consentIp = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? 'unbekannt'
const consentUserAgent = req.headers.get('user-agent') ?? 'unbekannt'
const result = await createSubscriber(tenantId, {
email: parsed.data.email,
listId: parsed.data.listId,
consentIp,
consentUserAgent,
})
if (!result.ok) {
const status = result.error.message.includes('bereits registriert') ? 409 : 500
return NextResponse.json({ error: result.error.message }, { status })
}
// Antwort enthält keine E-Mail-Adresse (DSGVO)
return NextResponse.json({ id: result.data.id, status: result.data.status }, { status: 201 })
}

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
vi.mock('../../../server/db/subscribers', () => ({
createSubscriber: vi.fn(),
confirmDoi: vi.fn(),
deleteSubscriber: vi.fn(),
}))
vi.mock('../../../lib/tenant-header', () => ({
getTenantId: vi.fn().mockReturnValue('tenant1'),
}))
import { createSubscriber, confirmDoi, deleteSubscriber } from '../../../server/db/subscribers'
import { getTenantId } from '../../../lib/tenant-header'
describe('POST /api/subscribers', () => {
beforeEach(() => {
vi.mocked(getTenantId).mockReturnValue('tenant1')
vi.mocked(createSubscriber).mockReset()
})
it('gibt 201 zurück bei gültiger Anmeldung', async () => {
vi.mocked(createSubscriber).mockResolvedValueOnce({
ok: true,
data: { id: 'sub-1', status: 'pending' } as never,
})
const { POST } = await import('./route')
const req = new NextRequest('http://localhost/api/subscribers', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1', 'x-forwarded-for': '1.2.3.4' },
body: JSON.stringify({ email: 'new@example.com' }),
})
const res = await POST(req)
expect(res.status).toBe(201)
})
it('Antwort enthält keine E-Mail-Adresse (DSGVO)', async () => {
vi.mocked(createSubscriber).mockResolvedValueOnce({
ok: true,
data: { id: 'sub-1', status: 'pending', email: 'secret@example.com' } as never,
})
const { POST } = await import('./route')
const req = new NextRequest('http://localhost/api/subscribers', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' },
body: JSON.stringify({ email: 'secret@example.com' }),
})
const res = await POST(req)
const body = await res.json() as Record<string, unknown>
expect(JSON.stringify(body)).not.toContain('secret@example.com')
})
it('gibt 409 zurück wenn E-Mail bereits existiert', async () => {
vi.mocked(createSubscriber).mockResolvedValueOnce({
ok: false,
error: new Error('E-Mail bereits registriert'),
})
const { POST } = await import('./route')
const req = new NextRequest('http://localhost/api/subscribers', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' },
body: JSON.stringify({ email: 'exists@example.com' }),
})
const res = await POST(req)
expect(res.status).toBe(409)
})
it('gibt 400 zurück bei ungültiger E-Mail', async () => {
const { POST } = await import('./route')
const req = new NextRequest('http://localhost/api/subscribers', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' },
body: JSON.stringify({ email: 'kein-email' }),
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('gibt 401 zurück wenn Tenant fehlt', async () => {
vi.mocked(getTenantId).mockImplementationOnce(() => { throw new Error('fehlt') })
const { POST } = await import('./route')
const req = new NextRequest('http://localhost/api/subscribers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'x@x.com' }),
})
const res = await POST(req)
expect(res.status).toBe(401)
})
})
describe('POST /api/subscribers/confirm', () => {
beforeEach(() => {
vi.mocked(getTenantId).mockReturnValue('tenant1')
vi.mocked(confirmDoi).mockReset()
})
it('gibt 200 bei gültigem Token', async () => {
vi.mocked(confirmDoi).mockResolvedValueOnce({
ok: true,
data: { status: 'active' } as never,
})
const { POST } = await import('./confirm/route')
const req = new NextRequest('http://localhost/api/subscribers/confirm', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' },
body: JSON.stringify({ token: '550e8400-e29b-41d4-a716-446655440000' }),
})
const res = await POST(req)
expect(res.status).toBe(200)
})
it('gibt 400 bei ungültigem Token', async () => {
vi.mocked(confirmDoi).mockResolvedValueOnce({
ok: false,
error: new Error('Ungültiger Token'),
})
const { POST } = await import('./confirm/route')
const req = new NextRequest('http://localhost/api/subscribers/confirm', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' },
body: JSON.stringify({ token: '550e8400-e29b-41d4-a716-446655440000' }),
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('gibt 400 bei fehlendem Token-Feld', async () => {
const { POST } = await import('./confirm/route')
const req = new NextRequest('http://localhost/api/subscribers/confirm', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'tenant1' },
body: JSON.stringify({}),
})
const res = await POST(req)
expect(res.status).toBe(400)
})
})
describe('DELETE /api/subscribers/[id]', () => {
beforeEach(() => {
vi.mocked(getTenantId).mockReturnValue('tenant1')
vi.mocked(deleteSubscriber).mockReset()
})
it('gibt 204 bei erfolgreichem Löschen', async () => {
vi.mocked(deleteSubscriber).mockResolvedValueOnce({ ok: true, data: undefined })
const { DELETE } = await import('./[id]/route')
const req = new NextRequest('http://localhost/api/subscribers/sub-1', {
method: 'DELETE',
headers: { 'x-tenant-id': 'tenant1' },
})
const res = await DELETE(req, { params: { id: 'sub-1' } })
expect(res.status).toBe(204)
})
it('gibt 404 wenn Subscriber nicht existiert', async () => {
vi.mocked(deleteSubscriber).mockResolvedValueOnce({
ok: false,
error: new Error('Subscriber nicht gefunden'),
})
const { DELETE } = await import('./[id]/route')
const req = new NextRequest('http://localhost/api/subscribers/missing', {
method: 'DELETE',
headers: { 'x-tenant-id': 'tenant1' },
})
const res = await DELETE(req, { params: { id: 'missing' } })
expect(res.status).toBe(404)
})
})