fix: DSGVO-Findings 5+7+6 — Tenant-Guard, APP_URL-Pflichtcheck, Constraint-Migration

- getTenantId wirft bei fehlendem Header statt silent fallback auf 'default'
- Alle API-Routes fangen den Fehler ab und antworten mit 401
- Worker gibt err() zurück wenn APP_URL nicht konfiguriert ist
- Migration: recipient_has_one Constraint schließt NULL/NULL-Zeilen aus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 16:42:50 +00:00
parent 25889a5419
commit ca0c65352e
9 changed files with 76 additions and 9 deletions

View File

@@ -4,7 +4,11 @@ import { getCampaign, updateCampaign } from '../../../../server/db/campaigns'
import { getTenantId } from '../../../../lib/tenant-header'
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const result = await getCampaign(getTenantId(req), params.id)
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const result = await getCampaign(tenantId, params.id)
if (!result.ok) {
const status = result.error.message.includes('nicht gefunden') ? 404 : 500
return NextResponse.json({ error: result.error.message }, { status })
@@ -13,7 +17,10 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const body = await req.json()
const parsed = UpdateCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })

View File

@@ -4,7 +4,10 @@ import { getCampaign, updateCampaignStatus } from '../../../../../server/db/camp
import { getTenantId } from '../../../../../lib/tenant-header'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const body = await req.json()
const parsed = ScheduleCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })

View File

@@ -7,7 +7,10 @@ import { checkSuppression } from '../../../../../server/suppression/check'
import { getTenantId } from '../../../../../lib/tenant-header'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const tenantId = getTenantId(req)
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const campaignResult = await getCampaign(tenantId, params.id)
if (!campaignResult.ok) {

View File

@@ -4,14 +4,20 @@ import { createCampaign, listCampaigns } from '../../../server/db/campaigns'
import { getTenantId } from '../../../lib/tenant-header'
export async function GET(req: NextRequest) {
const tenantId = getTenantId(req)
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const result = await listCampaigns(tenantId)
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 })
return NextResponse.json(result.data)
}
export async function POST(req: NextRequest) {
const tenantId = getTenantId(req)
let tenantId: string
try { tenantId = getTenantId(req) } catch {
return NextResponse.json({ error: 'Tenant nicht identifizierbar' }, { status: 401 })
}
const body = await req.json()
const parsed = CreateCampaignSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { NextRequest } from 'next/server'
import { getTenantId } from './tenant-header'
describe('getTenantId', () => {
it('gibt tenantId zurück wenn Header gesetzt', () => {
const req = new NextRequest('http://localhost/api', {
headers: { 'x-tenant-id': 'abc123' },
})
expect(getTenantId(req)).toBe('abc123')
})
it('wirft Fehler wenn x-tenant-id fehlt', () => {
const req = new NextRequest('http://localhost/api')
expect(() => getTenantId(req)).toThrow('x-tenant-id Header fehlt')
})
})

View File

@@ -1,5 +1,7 @@
import { NextRequest } from 'next/server'
export function getTenantId(req: NextRequest): string {
return req.headers.get('x-tenant-id') ?? 'default'
const tenantId = req.headers.get('x-tenant-id')
if (!tenantId) throw new Error('x-tenant-id Header fehlt')
return tenantId
}

View File

@@ -40,7 +40,10 @@ const jobData = {
}
describe('processEmailSendJob', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
process.env.APP_URL = 'http://localhost:3000'
})
it('sendet E-Mail wenn nicht suppressed', async () => {
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
@@ -133,4 +136,17 @@ describe('processEmailSendJob', () => {
expect(result.ok).toBe(true)
})
it('gibt err zurück wenn APP_URL nicht gesetzt', async () => {
const originalUrl = process.env.APP_URL
delete process.env.APP_URL
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
vi.mocked(checkSuppression).mockResolvedValue(false)
const result = await processEmailSendJob(jobData)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.message).toContain('APP_URL')
process.env.APP_URL = originalUrl
})
})

View File

@@ -18,7 +18,8 @@ export async function processEmailSendJob(data: EmailSendJobData): Promise<Resul
return ok(undefined)
}
const appUrl = process.env.APP_URL ?? 'http://localhost:3000'
const appUrl = process.env.APP_URL
if (!appUrl) return err(new Error('APP_URL nicht konfiguriert — Unsubscribe-Link kann nicht erstellt werden'))
const unsubUrl = `${appUrl}/unsub?tid=${data.tenantId}&cid=${data.campaignId}&r=${data.recipientHash}`
const sendResult = await sendEmail({