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:
18
src/app/api/subscribers/[id]/route.ts
Normal file
18
src/app/api/subscribers/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
22
src/app/api/subscribers/confirm/route.ts
Normal file
22
src/app/api/subscribers/confirm/route.ts
Normal 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' })
|
||||||
|
}
|
||||||
38
src/app/api/subscribers/route.ts
Normal file
38
src/app/api/subscribers/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
170
src/app/api/subscribers/subscribers-api.test.ts
Normal file
170
src/app/api/subscribers/subscribers-api.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user