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:
@@ -0,0 +1,12 @@
|
|||||||
|
-- Verhindert campaign_recipients-Zeilen ohne list_id UND segment_id
|
||||||
|
-- NOT VALID: läuft ohne Table-Lock, bestehende Zeilen beim nächsten VALIDATE CONSTRAINT prüfen
|
||||||
|
|
||||||
|
ALTER TABLE campaign_recipients
|
||||||
|
DROP CONSTRAINT IF EXISTS recipient_has_one;
|
||||||
|
|
||||||
|
ALTER TABLE campaign_recipients
|
||||||
|
ADD CONSTRAINT recipient_has_one CHECK (
|
||||||
|
(list_id IS NOT NULL AND segment_id IS NULL)
|
||||||
|
OR
|
||||||
|
(segment_id IS NOT NULL AND list_id IS NULL)
|
||||||
|
) NOT VALID;
|
||||||
@@ -4,7 +4,11 @@ import { getCampaign, updateCampaign } from '../../../../server/db/campaigns'
|
|||||||
import { getTenantId } from '../../../../lib/tenant-header'
|
import { getTenantId } from '../../../../lib/tenant-header'
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
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) {
|
if (!result.ok) {
|
||||||
const status = result.error.message.includes('nicht gefunden') ? 404 : 500
|
const status = result.error.message.includes('nicht gefunden') ? 404 : 500
|
||||||
return NextResponse.json({ error: result.error.message }, { status })
|
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 } }) {
|
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 body = await req.json()
|
||||||
const parsed = UpdateCampaignSchema.safeParse(body)
|
const parsed = UpdateCampaignSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { getCampaign, updateCampaignStatus } from '../../../../../server/db/camp
|
|||||||
import { getTenantId } from '../../../../../lib/tenant-header'
|
import { getTenantId } from '../../../../../lib/tenant-header'
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
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 body = await req.json()
|
||||||
const parsed = ScheduleCampaignSchema.safeParse(body)
|
const parsed = ScheduleCampaignSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { checkSuppression } from '../../../../../server/suppression/check'
|
|||||||
import { getTenantId } from '../../../../../lib/tenant-header'
|
import { getTenantId } from '../../../../../lib/tenant-header'
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
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)
|
const campaignResult = await getCampaign(tenantId, params.id)
|
||||||
if (!campaignResult.ok) {
|
if (!campaignResult.ok) {
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { createCampaign, listCampaigns } from '../../../server/db/campaigns'
|
|||||||
import { getTenantId } from '../../../lib/tenant-header'
|
import { getTenantId } from '../../../lib/tenant-header'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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)
|
const result = await listCampaigns(tenantId)
|
||||||
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 })
|
if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 })
|
||||||
return NextResponse.json(result.data)
|
return NextResponse.json(result.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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 body = await req.json()
|
||||||
const parsed = CreateCampaignSchema.safeParse(body)
|
const parsed = CreateCampaignSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
|
||||||
|
|||||||
17
src/lib/tenant-header.test.ts
Normal file
17
src/lib/tenant-header.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
export function getTenantId(req: NextRequest): string {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ const jobData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('processEmailSendJob', () => {
|
describe('processEmailSendJob', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.APP_URL = 'http://localhost:3000'
|
||||||
|
})
|
||||||
|
|
||||||
it('sendet E-Mail wenn nicht suppressed', async () => {
|
it('sendet E-Mail wenn nicht suppressed', async () => {
|
||||||
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
||||||
@@ -133,4 +136,17 @@ describe('processEmailSendJob', () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true)
|
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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export async function processEmailSendJob(data: EmailSendJobData): Promise<Resul
|
|||||||
return ok(undefined)
|
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 unsubUrl = `${appUrl}/unsub?tid=${data.tenantId}&cid=${data.campaignId}&r=${data.recipientHash}`
|
||||||
|
|
||||||
const sendResult = await sendEmail({
|
const sendResult = await sendEmail({
|
||||||
|
|||||||
Reference in New Issue
Block a user