44acd68c96
Phase 1: Vendor sync from service registry (82+ services → banner vendors) Phase 2: Category-based retention (marketing=90d, statistics=790d, not hardcoded 365d) Phase 3: DSR ↔ Banner email linking (link-email, by-email, Art.17 erasure, Art.15/20 export) Phase 4: Consent sync (Banner → Einwilligungen bridge) Phase 6: Consent proof (SHA256 config hash + config_version in audit log, Art. 7(1) DSGVO) New files: - banner_dsr_service.py — email linking + DSR integration - vendor_banner_sync.py — service registry → vendor configs - migration 106 — linked_email, banner_config_hash, consent_version columns Tests: 20+ new backend tests + 2 Playwright E2E test suites (API + UI) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
|
|
/**
|
|
* Banner Consent API Integration Tests
|
|
*
|
|
* Tests the complete lifecycle of cookie banner consents:
|
|
* - Record/retrieve/withdraw consent
|
|
* - Email linking for DSR integration
|
|
* - Consent export (Art. 15/20 DSGVO)
|
|
* - Consent deletion (Art. 17 DSGVO erasure)
|
|
* - Consent sync to Einwilligungen
|
|
* - Vendor sync from service registry
|
|
* - Site config with config_version for consent proof
|
|
*/
|
|
|
|
const API_BASE = process.env.PLAYWRIGHT_API_URL || 'https://macmini:8093'
|
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
|
const HEADERS = {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': TENANT_ID,
|
|
}
|
|
|
|
// Test data
|
|
const TEST_SITE_ID = `e2e-banner-test-${Date.now()}`
|
|
const TEST_DEVICE_FP = `e2e-device-${Date.now()}`
|
|
const TEST_EMAIL = `e2e-test-${Date.now()}@example.com`
|
|
|
|
test.describe('Banner Consent API — Full Lifecycle', () => {
|
|
let siteConfigId: string | null = null
|
|
let consentId: string | null = null
|
|
|
|
// ─── Setup: Create site config ───────────────────────────
|
|
test('01 — Create site config', async ({ request }) => {
|
|
const res = await request.post(`${API_BASE}/banner/admin/sites`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: TEST_SITE_ID,
|
|
site_name: 'E2E Test Site',
|
|
site_url: 'https://e2e-test.example.com',
|
|
banner_title: 'Cookie-Einstellungen',
|
|
banner_description: 'Wir verwenden Cookies fuer E2E Tests.',
|
|
privacy_url: '/datenschutz',
|
|
imprint_url: '/impressum',
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.site_id).toBe(TEST_SITE_ID)
|
|
expect(body.config_version).toBe(1)
|
|
siteConfigId = body.id
|
|
})
|
|
|
|
test('02 — Create categories for site', async ({ request }) => {
|
|
const categories = [
|
|
{ category_key: 'necessary', name_de: 'Notwendig', is_required: true, sort_order: 0 },
|
|
{ category_key: 'statistics', name_de: 'Statistik', is_required: false, sort_order: 1 },
|
|
{ category_key: 'marketing', name_de: 'Marketing', is_required: false, sort_order: 2 },
|
|
]
|
|
for (const cat of categories) {
|
|
const res = await request.post(`${API_BASE}/banner/admin/sites/${TEST_SITE_ID}/categories`, {
|
|
headers: HEADERS,
|
|
data: cat,
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
}
|
|
})
|
|
|
|
test('03 — Create vendor configs', async ({ request }) => {
|
|
const vendors = [
|
|
{ vendor_name: 'Google Analytics', category_key: 'statistics', retention_days: 790, cookie_names: ['_ga', '_gid'] },
|
|
{ vendor_name: 'Facebook Pixel', category_key: 'marketing', retention_days: 90, cookie_names: ['_fbp'] },
|
|
]
|
|
for (const v of vendors) {
|
|
const res = await request.post(`${API_BASE}/banner/admin/sites/${TEST_SITE_ID}/vendors`, {
|
|
headers: HEADERS,
|
|
data: v,
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
}
|
|
})
|
|
|
|
// ─── Core Consent CRUD ───────────────────────────────────
|
|
test('04 — Get site config for banner display', async ({ request }) => {
|
|
const res = await request.get(`${API_BASE}/banner/config/${TEST_SITE_ID}`, { headers: HEADERS })
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.site_id).toBe(TEST_SITE_ID)
|
|
expect(body.banner_title).toBe('Cookie-Einstellungen')
|
|
expect(body.categories.length).toBe(3)
|
|
expect(body.vendors.length).toBe(2)
|
|
expect(body.config_version).toBe(1)
|
|
})
|
|
|
|
test('05 — Record consent (accept statistics only)', async ({ request }) => {
|
|
const res = await request.post(`${API_BASE}/banner/consent`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: TEST_SITE_ID,
|
|
device_fingerprint: TEST_DEVICE_FP,
|
|
categories: ['necessary', 'statistics'],
|
|
vendors: ['Google Analytics'],
|
|
ip_address: '192.168.1.100',
|
|
user_agent: 'Playwright E2E Test',
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.device_fingerprint).toBe(TEST_DEVICE_FP)
|
|
expect(body.categories).toEqual(['necessary', 'statistics'])
|
|
expect(body.vendors).toEqual(['Google Analytics'])
|
|
expect(body.ip_hash).toBeTruthy() // IP is hashed
|
|
expect(body.linked_email).toBeNull()
|
|
consentId = body.id
|
|
})
|
|
|
|
test('06 — Retrieve consent for device', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent?site_id=${TEST_SITE_ID}&device_fingerprint=${TEST_DEVICE_FP}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.has_consent).toBe(true)
|
|
expect(body.consent.categories).toEqual(['necessary', 'statistics'])
|
|
})
|
|
|
|
test('07 — Update consent (accept all categories)', async ({ request }) => {
|
|
const res = await request.post(`${API_BASE}/banner/consent`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: TEST_SITE_ID,
|
|
device_fingerprint: TEST_DEVICE_FP,
|
|
categories: ['necessary', 'statistics', 'marketing'],
|
|
vendors: ['Google Analytics', 'Facebook Pixel'],
|
|
ip_address: '192.168.1.100',
|
|
user_agent: 'Playwright E2E Test',
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.categories).toEqual(['necessary', 'statistics', 'marketing'])
|
|
expect(body.id).toBe(consentId) // Same consent row (upsert)
|
|
})
|
|
|
|
// ─── Phase 3: Email Linking ──────────────────────────────
|
|
test('08 — Link email to device fingerprint', async ({ request }) => {
|
|
const res = await request.post(`${API_BASE}/banner/consent/link-email`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: TEST_SITE_ID,
|
|
device_fingerprint: TEST_DEVICE_FP,
|
|
email: TEST_EMAIL,
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.linked_email).toBe(TEST_EMAIL.toLowerCase())
|
|
expect(body.device_fingerprint).toBe(TEST_DEVICE_FP)
|
|
})
|
|
|
|
test('09 — Find consents by email (Art. 15)', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(TEST_EMAIL)}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.length).toBe(1)
|
|
expect(body[0].linked_email).toBe(TEST_EMAIL.toLowerCase())
|
|
expect(body[0].device_fingerprint).toBe(TEST_DEVICE_FP)
|
|
})
|
|
|
|
test('10 — Export consent data for DSR (Art. 15/20)', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent/dsr-export/${encodeURIComponent(TEST_EMAIL)}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.email).toBe(TEST_EMAIL.toLowerCase())
|
|
expect(body.banner_consents.length).toBe(1)
|
|
expect(body.audit_trail.length).toBeGreaterThan(0)
|
|
// Verify consent proof fields in audit trail
|
|
const lastAudit = body.audit_trail[0]
|
|
expect(lastAudit.action).toBeTruthy()
|
|
expect(lastAudit.site_id).toBe(TEST_SITE_ID)
|
|
})
|
|
|
|
// ─── Phase 4: Consent Sync ──────────────────────────────
|
|
test('11 — Sync banner consent to Einwilligungen', async ({ request }) => {
|
|
const res = await request.post(`${API_BASE}/banner/consent/sync`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: TEST_SITE_ID,
|
|
device_fingerprint: TEST_DEVICE_FP,
|
|
email: TEST_EMAIL,
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.synced).toBeGreaterThan(0)
|
|
expect(body.categories).toContain('necessary')
|
|
expect(body.categories).toContain('statistics')
|
|
expect(body.categories).toContain('marketing')
|
|
expect(body.email).toBe(TEST_EMAIL.toLowerCase())
|
|
})
|
|
|
|
// ─── DSGVO Export ────────────────────────────────────────
|
|
test('12 — Export consent per device (existing endpoint)', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent/export?site_id=${TEST_SITE_ID}&device_fingerprint=${TEST_DEVICE_FP}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.device_fingerprint).toBe(TEST_DEVICE_FP)
|
|
expect(body.consents.length).toBe(1)
|
|
expect(body.audit_trail.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
// ─── Stats ───────────────────────────────────────────────
|
|
test('13 — Get site consent statistics', async ({ request }) => {
|
|
const res = await request.get(`${API_BASE}/banner/admin/stats/${TEST_SITE_ID}`, {
|
|
headers: HEADERS,
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.site_id).toBe(TEST_SITE_ID)
|
|
expect(body.total_consents).toBeGreaterThan(0)
|
|
expect(body.category_acceptance.necessary).toBeTruthy()
|
|
expect(body.category_acceptance.statistics).toBeTruthy()
|
|
})
|
|
|
|
// ─── Withdraw + Cleanup ─────────────────────────────────
|
|
test('14 — Withdraw consent', async ({ request }) => {
|
|
expect(consentId).toBeTruthy()
|
|
const res = await request.delete(`${API_BASE}/banner/consent/${consentId}`, {
|
|
headers: HEADERS,
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.success).toBe(true)
|
|
})
|
|
|
|
test('15 — Verify consent withdrawn (no consent found)', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent?site_id=${TEST_SITE_ID}&device_fingerprint=${TEST_DEVICE_FP}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.has_consent).toBe(false)
|
|
})
|
|
|
|
// ─── Cleanup: Delete site config ────────────────────────
|
|
test('16 — Cleanup: Delete test site', async ({ request }) => {
|
|
const res = await request.delete(`${API_BASE}/banner/admin/sites/${TEST_SITE_ID}`, {
|
|
headers: HEADERS,
|
|
})
|
|
expect(res.status()).toBe(204)
|
|
})
|
|
})
|
|
|
|
|
|
test.describe('Banner Consent API — Art. 17 Erasure via Email', () => {
|
|
const ERASURE_SITE = `e2e-erasure-${Date.now()}`
|
|
const ERASURE_DEVICE_1 = `e2e-dev1-${Date.now()}`
|
|
const ERASURE_DEVICE_2 = `e2e-dev2-${Date.now()}`
|
|
const ERASURE_EMAIL = `erasure-${Date.now()}@example.com`
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
// Create site
|
|
await request.post(`${API_BASE}/banner/admin/sites`, {
|
|
headers: HEADERS,
|
|
data: { site_id: ERASURE_SITE, site_name: 'Erasure Test Site' },
|
|
})
|
|
// Record consent on two devices with same email
|
|
for (const fp of [ERASURE_DEVICE_1, ERASURE_DEVICE_2]) {
|
|
await request.post(`${API_BASE}/banner/consent`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: ERASURE_SITE,
|
|
device_fingerprint: fp,
|
|
categories: ['necessary', 'statistics'],
|
|
vendors: [],
|
|
},
|
|
})
|
|
await request.post(`${API_BASE}/banner/consent/link-email`, {
|
|
headers: HEADERS,
|
|
data: { site_id: ERASURE_SITE, device_fingerprint: fp, email: ERASURE_EMAIL },
|
|
})
|
|
}
|
|
})
|
|
|
|
test('should find 2 consents for email', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(ERASURE_EMAIL)}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.length).toBe(2)
|
|
})
|
|
|
|
test('should delete all consents by email (Art. 17)', async ({ request }) => {
|
|
const res = await request.delete(
|
|
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(ERASURE_EMAIL)}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.deleted).toBe(2)
|
|
expect(body.email).toBe(ERASURE_EMAIL.toLowerCase())
|
|
})
|
|
|
|
test('should find 0 consents after erasure', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(ERASURE_EMAIL)}`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.length).toBe(0)
|
|
})
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
await request.delete(`${API_BASE}/banner/admin/sites/${ERASURE_SITE}`, {
|
|
headers: HEADERS,
|
|
})
|
|
})
|
|
})
|
|
|
|
|
|
test.describe('Banner Consent API — Vendor Sync from Registry', () => {
|
|
const SYNC_SITE = `e2e-sync-${Date.now()}`
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
await request.post(`${API_BASE}/banner/admin/sites`, {
|
|
headers: HEADERS,
|
|
data: { site_id: SYNC_SITE, site_name: 'Vendor Sync Test' },
|
|
})
|
|
})
|
|
|
|
test('should sync vendors from service registry', async ({ request }) => {
|
|
const res = await request.post(
|
|
`${API_BASE}/banner/admin/sites/${SYNC_SITE}/sync-vendors`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.total).toBeGreaterThan(0)
|
|
expect(body.created).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('should list synced vendors', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/admin/sites/${SYNC_SITE}/vendors`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const vendors = await res.json()
|
|
expect(vendors.length).toBeGreaterThan(0)
|
|
|
|
// Verify vendor structure
|
|
const ga = vendors.find((v: any) => v.vendor_name === 'Google Analytics')
|
|
if (ga) {
|
|
expect(ga.category_key).toBe('statistics')
|
|
expect(ga.retention_days).toBe(790) // 26 months
|
|
}
|
|
})
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
await request.delete(`${API_BASE}/banner/admin/sites/${SYNC_SITE}`, {
|
|
headers: HEADERS,
|
|
})
|
|
})
|
|
})
|
|
|
|
|
|
test.describe('Banner Consent API — Edge Cases & Validation', () => {
|
|
test('should return has_consent=false for unknown device', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent?site_id=nonexistent&device_fingerprint=unknown`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.has_consent).toBe(false)
|
|
})
|
|
|
|
test('should return default config for unconfigured site', async ({ request }) => {
|
|
const res = await request.get(`${API_BASE}/banner/config/nonexistent-site`, {
|
|
headers: HEADERS,
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body.site_id).toBe('nonexistent-site')
|
|
expect(body.banner_title).toBe('Cookie-Einstellungen')
|
|
expect(body.categories).toEqual([])
|
|
})
|
|
|
|
test('should reject invalid email for link-email', async ({ request }) => {
|
|
const res = await request.post(`${API_BASE}/banner/consent/link-email`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: 'test',
|
|
device_fingerprint: 'test',
|
|
email: 'not-an-email',
|
|
},
|
|
})
|
|
// Should fail — either 400 or 404 (no consent found)
|
|
expect(res.status()).toBeGreaterThanOrEqual(400)
|
|
})
|
|
|
|
test('should return empty list for unknown email in by-email', async ({ request }) => {
|
|
const res = await request.get(
|
|
`${API_BASE}/banner/consent/by-email/nobody@nowhere.test`,
|
|
{ headers: HEADERS },
|
|
)
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
expect(body).toEqual([])
|
|
})
|
|
|
|
test('should hash IP address (never store plain IP)', async ({ request }) => {
|
|
const siteId = `e2e-ip-test-${Date.now()}`
|
|
const fp = `e2e-ip-fp-${Date.now()}`
|
|
|
|
// Create site
|
|
await request.post(`${API_BASE}/banner/admin/sites`, {
|
|
headers: HEADERS,
|
|
data: { site_id: siteId, site_name: 'IP Test' },
|
|
})
|
|
|
|
// Record consent with IP
|
|
const res = await request.post(`${API_BASE}/banner/consent`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: siteId,
|
|
device_fingerprint: fp,
|
|
categories: ['necessary'],
|
|
vendors: [],
|
|
ip_address: '10.0.0.42',
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
|
|
// IP should be hashed, not stored plain
|
|
expect(body.ip_hash).toBeTruthy()
|
|
expect(body.ip_hash).not.toBe('10.0.0.42')
|
|
expect(body.ip_hash.length).toBe(16) // SHA256[:16]
|
|
|
|
// Cleanup
|
|
await request.delete(`${API_BASE}/banner/consent/${body.id}`, { headers: HEADERS })
|
|
await request.delete(`${API_BASE}/banner/admin/sites/${siteId}`, { headers: HEADERS })
|
|
})
|
|
})
|
|
|
|
|
|
test.describe('Banner Consent API — Retention per Category', () => {
|
|
const RET_SITE = `e2e-retention-${Date.now()}`
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
// Create site with categories and vendors
|
|
await request.post(`${API_BASE}/banner/admin/sites`, {
|
|
headers: HEADERS,
|
|
data: { site_id: RET_SITE, site_name: 'Retention Test' },
|
|
})
|
|
await request.post(`${API_BASE}/banner/admin/sites/${RET_SITE}/categories`, {
|
|
headers: HEADERS,
|
|
data: { category_key: 'necessary', name_de: 'Notwendig', is_required: true },
|
|
})
|
|
await request.post(`${API_BASE}/banner/admin/sites/${RET_SITE}/categories`, {
|
|
headers: HEADERS,
|
|
data: { category_key: 'marketing', name_de: 'Marketing', is_required: false },
|
|
})
|
|
await request.post(`${API_BASE}/banner/admin/sites/${RET_SITE}/vendors`, {
|
|
headers: HEADERS,
|
|
data: { vendor_name: 'FB Pixel', category_key: 'marketing', retention_days: 90 },
|
|
})
|
|
})
|
|
|
|
test('consent expiry should match max vendor retention', async ({ request }) => {
|
|
const fp = `e2e-ret-fp-${Date.now()}`
|
|
const res = await request.post(`${API_BASE}/banner/consent`, {
|
|
headers: HEADERS,
|
|
data: {
|
|
site_id: RET_SITE,
|
|
device_fingerprint: fp,
|
|
categories: ['necessary', 'marketing'],
|
|
vendors: ['FB Pixel'],
|
|
},
|
|
})
|
|
expect(res.ok()).toBeTruthy()
|
|
const body = await res.json()
|
|
|
|
// Expiry should be based on max vendor retention (90 days for marketing)
|
|
const expiresAt = new Date(body.expires_at)
|
|
const createdAt = new Date(body.created_at)
|
|
const diffDays = Math.round((expiresAt.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
|
// Should be 90 days (marketing) not 365 (default)
|
|
expect(diffDays).toBeGreaterThanOrEqual(89)
|
|
expect(diffDays).toBeLessThanOrEqual(91)
|
|
|
|
// Cleanup
|
|
await request.delete(`${API_BASE}/banner/consent/${body.id}`, { headers: HEADERS })
|
|
})
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
await request.delete(`${API_BASE}/banner/admin/sites/${RET_SITE}`, {
|
|
headers: HEADERS,
|
|
})
|
|
})
|
|
})
|