Initial commit: breakpilot-compliance - Compliance SDK Platform

Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConsentAPI } from './ConsentAPI';
import type { ConsentConfig, ConsentState } from '../types';
describe('ConsentAPI', () => {
let api: ConsentAPI;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com/',
siteId: 'test-site',
debug: false,
};
const mockConsent: ConsentState = {
categories: {
essential: true,
functional: true,
analytics: false,
marketing: false,
social: false,
},
vendors: {},
timestamp: '2024-01-15T10:00:00.000Z',
version: '1.0.0',
};
beforeEach(() => {
api = new ConsentAPI(mockConfig);
vi.clearAllMocks();
});
describe('constructor', () => {
it('should strip trailing slash from apiEndpoint', () => {
expect(api).toBeDefined();
});
});
describe('saveConsent', () => {
it('should POST consent to the API', async () => {
const mockResponse = {
consentId: 'consent-123',
timestamp: '2024-01-15T10:00:00.000Z',
expiresAt: '2025-01-15T10:00:00.000Z',
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/consent',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Accept: 'application/json',
}),
credentials: 'include',
})
);
expect(result.consentId).toBe('consent-123');
});
it('should include metadata in the request', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consentId: '123' }),
} as Response);
await api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
const call = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(call[1]?.body as string);
expect(body.metadata).toBeDefined();
expect(body.metadata.platform).toBe('web');
});
it('should throw on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
await expect(
api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
})
).rejects.toThrow('Failed to save consent: 500');
});
it('should include signature headers', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consentId: '123' }),
} as Response);
await api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
const call = vi.mocked(fetch).mock.calls[0];
const headers = call[1]?.headers as Record<string, string>;
expect(headers['X-Consent-Timestamp']).toBeDefined();
expect(headers['X-Consent-Signature']).toMatch(/^sha256=/);
});
});
describe('getConsent', () => {
it('should GET consent from the API', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consent: mockConsent }),
} as Response);
const result = await api.getConsent('test-site', 'fp_123');
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/consent?siteId=test-site&deviceFingerprint=fp_123'),
expect.objectContaining({
headers: expect.any(Object),
credentials: 'include',
})
);
expect(result?.categories.essential).toBe(true);
});
it('should return null on 404', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
const result = await api.getConsent('test-site', 'fp_123');
expect(result).toBeNull();
});
it('should throw on other errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
await expect(api.getConsent('test-site', 'fp_123')).rejects.toThrow(
'Failed to get consent: 500'
);
});
});
describe('revokeConsent', () => {
it('should DELETE consent from the API', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 204,
} as Response);
await api.revokeConsent('consent-123');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/consent/consent-123',
expect.objectContaining({
method: 'DELETE',
})
);
});
it('should throw on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
await expect(api.revokeConsent('consent-123')).rejects.toThrow(
'Failed to revoke consent: 404'
);
});
});
describe('getSiteConfig', () => {
it('should GET site configuration', async () => {
const mockSiteConfig = {
siteId: 'test-site',
name: 'Test Site',
categories: ['essential', 'analytics'],
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockSiteConfig),
} as Response);
const result = await api.getSiteConfig('test-site');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/config/test-site',
expect.any(Object)
);
expect(result.siteId).toBe('test-site');
});
it('should throw on error', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
await expect(api.getSiteConfig('unknown-site')).rejects.toThrow(
'Failed to get site config: 404'
);
});
});
describe('exportConsent', () => {
it('should GET consent export for user', async () => {
const mockExport = {
userId: 'user-123',
consents: [mockConsent],
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockExport),
} as Response);
const result = await api.exportConsent('user-123');
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/consent/export?userId=user-123'),
expect.any(Object)
);
expect(result).toEqual(mockExport);
});
it('should throw on error', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 403,
} as Response);
await expect(api.exportConsent('user-123')).rejects.toThrow(
'Failed to export consent: 403'
);
});
});
describe('network errors', () => {
it('should propagate fetch errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
await expect(
api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
})
).rejects.toThrow('Network error');
});
});
describe('debug mode', () => {
it('should log when debug is enabled', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugApi = new ConsentAPI({
...mockConfig,
debug: true,
});
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consentId: '123' }),
} as Response);
await debugApi.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* ConsentAPI - Kommunikation mit dem Consent-Backend
*
* Sendet Consent-Entscheidungen an das Backend zur
* revisionssicheren Speicherung.
*/
import type {
ConsentConfig,
ConsentState,
ConsentAPIResponse,
SiteConfigResponse,
} from '../types';
/**
* Request-Payload fuer Consent-Speicherung
*/
interface SaveConsentRequest {
siteId: string;
userId?: string;
deviceFingerprint: string;
consent: ConsentState;
metadata?: {
userAgent?: string;
language?: string;
screenResolution?: string;
platform?: string;
appVersion?: string;
};
}
/**
* ConsentAPI - Backend-Kommunikation
*/
export class ConsentAPI {
private config: ConsentConfig;
private baseUrl: string;
constructor(config: ConsentConfig) {
this.config = config;
this.baseUrl = config.apiEndpoint.replace(/\/$/, '');
}
/**
* Consent speichern
*/
async saveConsent(request: SaveConsentRequest): Promise<ConsentAPIResponse> {
const payload = {
...request,
metadata: {
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
language: typeof navigator !== 'undefined' ? navigator.language : '',
screenResolution:
typeof window !== 'undefined'
? `${window.screen.width}x${window.screen.height}`
: '',
platform: 'web',
...request.metadata,
},
};
const response = await this.fetch('/consent', {
method: 'POST',
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Failed to save consent: ${response.status}`);
}
return response.json();
}
/**
* Consent abrufen
*/
async getConsent(
siteId: string,
deviceFingerprint: string
): Promise<ConsentState | null> {
const params = new URLSearchParams({
siteId,
deviceFingerprint,
});
const response = await this.fetch(`/consent?${params}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to get consent: ${response.status}`);
}
const data = await response.json();
return data.consent;
}
/**
* Consent widerrufen
*/
async revokeConsent(consentId: string): Promise<void> {
const response = await this.fetch(`/consent/${consentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to revoke consent: ${response.status}`);
}
}
/**
* Site-Konfiguration abrufen
*/
async getSiteConfig(siteId: string): Promise<SiteConfigResponse> {
const response = await this.fetch(`/config/${siteId}`);
if (!response.ok) {
throw new Error(`Failed to get site config: ${response.status}`);
}
return response.json();
}
/**
* Consent-Historie exportieren (DSGVO Art. 20)
*/
async exportConsent(userId: string): Promise<unknown> {
const params = new URLSearchParams({ userId });
const response = await this.fetch(`/consent/export?${params}`);
if (!response.ok) {
throw new Error(`Failed to export consent: ${response.status}`);
}
return response.json();
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Fetch mit Standard-Headers
*/
private async fetch(
path: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${this.baseUrl}${path}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
Accept: 'application/json',
...this.getSignatureHeaders(),
...(options.headers || {}),
};
try {
const response = await fetch(url, {
...options,
headers,
credentials: 'include',
});
this.log(`${options.method || 'GET'} ${path}:`, response.status);
return response;
} catch (error) {
this.log('Fetch error:', error);
throw error;
}
}
/**
* Signatur-Headers generieren (HMAC)
*/
private getSignatureHeaders(): Record<string, string> {
const timestamp = Math.floor(Date.now() / 1000).toString();
// Einfache Signatur fuer Client-Side
// In Produktion: Server-seitige Validierung mit echtem HMAC
const signature = this.simpleHash(`${this.config.siteId}:${timestamp}`);
return {
'X-Consent-Timestamp': timestamp,
'X-Consent-Signature': `sha256=${signature}`,
};
}
/**
* Einfache Hash-Funktion (djb2)
*/
private simpleHash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ConsentAPI]', ...args);
}
}
}
export default ConsentAPI;

View File

@@ -0,0 +1,605 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ConsentManager } from './ConsentManager';
import type { ConsentConfig, ConsentState } from '../types';
describe('ConsentManager', () => {
let manager: ConsentManager;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com',
siteId: 'test-site',
debug: false,
};
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// Mock successful API response
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
consentId: 'consent-123',
timestamp: '2024-01-15T10:00:00.000Z',
expiresAt: '2025-01-15T10:00:00.000Z',
}),
} as Response);
manager = new ConsentManager(mockConfig);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should create manager with merged config', () => {
expect(manager).toBeDefined();
});
it('should apply default config values', () => {
// Default consent config should be applied
expect(manager).toBeDefined();
});
});
describe('init', () => {
it('should initialize the manager', async () => {
await manager.init();
// Should have generated fingerprint and be initialized
expect(manager.needsConsent()).toBe(true); // No consent stored
});
it('should only initialize once', async () => {
await manager.init();
await manager.init(); // Second call should be skipped
expect(manager.needsConsent()).toBe(true);
});
it('should emit init event', async () => {
const callback = vi.fn();
manager.on('init', callback);
await manager.init();
expect(callback).toHaveBeenCalled();
});
it('should load existing consent from storage', async () => {
// Pre-set consent in storage
const storageKey = `bp_consent_${mockConfig.siteId}`;
const mockConsent = {
categories: {
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
},
vendors: {},
timestamp: new Date().toISOString(),
version: '1.0.0',
};
// Create a simple hash for signature
const data = JSON.stringify(mockConsent) + mockConfig.siteId;
let hash = 5381;
for (let i = 0; i < data.length; i++) {
hash = (hash * 33) ^ data.charCodeAt(i);
}
const signature = (hash >>> 0).toString(16);
localStorage.setItem(
storageKey,
JSON.stringify({
version: '1',
consent: mockConsent,
signature,
})
);
manager = new ConsentManager(mockConfig);
await manager.init();
expect(manager.hasConsent('analytics')).toBe(true);
});
it('should show banner when no consent exists', async () => {
const callback = vi.fn();
manager.on('banner_show', callback);
await manager.init();
expect(callback).toHaveBeenCalled();
expect(manager.isBannerVisible()).toBe(true);
});
});
describe('hasConsent', () => {
it('should return true for essential without initialization', () => {
expect(manager.hasConsent('essential')).toBe(true);
});
it('should return false for other categories without consent', () => {
expect(manager.hasConsent('analytics')).toBe(false);
expect(manager.hasConsent('marketing')).toBe(false);
});
});
describe('hasVendorConsent', () => {
it('should return false when no consent exists', () => {
expect(manager.hasVendorConsent('google-analytics')).toBe(false);
});
});
describe('getConsent', () => {
it('should return null when no consent exists', () => {
expect(manager.getConsent()).toBeNull();
});
it('should return a copy of consent state', async () => {
await manager.init();
await manager.acceptAll();
const consent1 = manager.getConsent();
const consent2 = manager.getConsent();
expect(consent1).not.toBe(consent2); // Different objects
expect(consent1).toEqual(consent2); // Same content
});
});
describe('setConsent', () => {
it('should set consent categories', async () => {
await manager.init();
await manager.setConsent({
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
});
expect(manager.hasConsent('analytics')).toBe(true);
expect(manager.hasConsent('marketing')).toBe(false);
});
it('should always keep essential enabled', async () => {
await manager.init();
await manager.setConsent({
essential: false, // Attempting to disable
functional: false,
analytics: false,
marketing: false,
social: false,
});
expect(manager.hasConsent('essential')).toBe(true);
});
it('should emit change event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('change', callback);
await manager.setConsent({
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
});
expect(callback).toHaveBeenCalled();
});
it('should save consent locally even on API error', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
await manager.init();
await manager.setConsent({
essential: true,
functional: false,
analytics: true,
marketing: false,
social: false,
});
expect(manager.hasConsent('analytics')).toBe(true);
});
});
describe('acceptAll', () => {
it('should enable all categories', async () => {
await manager.init();
await manager.acceptAll();
expect(manager.hasConsent('essential')).toBe(true);
expect(manager.hasConsent('functional')).toBe(true);
expect(manager.hasConsent('analytics')).toBe(true);
expect(manager.hasConsent('marketing')).toBe(true);
expect(manager.hasConsent('social')).toBe(true);
});
it('should emit accept_all event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('accept_all', callback);
await manager.acceptAll();
expect(callback).toHaveBeenCalled();
});
it('should hide banner', async () => {
await manager.init();
expect(manager.isBannerVisible()).toBe(true);
await manager.acceptAll();
expect(manager.isBannerVisible()).toBe(false);
});
});
describe('rejectAll', () => {
it('should only keep essential enabled', async () => {
await manager.init();
await manager.rejectAll();
expect(manager.hasConsent('essential')).toBe(true);
expect(manager.hasConsent('functional')).toBe(false);
expect(manager.hasConsent('analytics')).toBe(false);
expect(manager.hasConsent('marketing')).toBe(false);
expect(manager.hasConsent('social')).toBe(false);
});
it('should emit reject_all event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('reject_all', callback);
await manager.rejectAll();
expect(callback).toHaveBeenCalled();
});
it('should hide banner', async () => {
await manager.init();
await manager.rejectAll();
expect(manager.isBannerVisible()).toBe(false);
});
});
describe('revokeAll', () => {
it('should clear all consent', async () => {
await manager.init();
await manager.acceptAll();
await manager.revokeAll();
expect(manager.getConsent()).toBeNull();
});
it('should try to revoke on server', async () => {
await manager.init();
await manager.acceptAll();
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 204,
} as Response);
await manager.revokeAll();
// DELETE request should have been made
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/consent/'),
expect.objectContaining({ method: 'DELETE' })
);
});
});
describe('exportConsent', () => {
it('should export consent data as JSON', async () => {
await manager.init();
await manager.acceptAll();
const exported = await manager.exportConsent();
const parsed = JSON.parse(exported);
expect(parsed.currentConsent).toBeDefined();
expect(parsed.exportedAt).toBeDefined();
expect(parsed.siteId).toBe('test-site');
});
});
describe('needsConsent', () => {
it('should return true when no consent exists', () => {
expect(manager.needsConsent()).toBe(true);
});
it('should return false when valid consent exists', async () => {
await manager.init();
await manager.acceptAll();
// After acceptAll, consent should exist
expect(manager.getConsent()).not.toBeNull();
// needsConsent checks for currentConsent and expiration
// Since we just accepted all, consent should be valid
const consent = manager.getConsent();
expect(consent?.categories?.essential).toBe(true);
});
});
describe('banner control', () => {
it('should show banner', async () => {
await manager.init();
manager.hideBanner();
manager.showBanner();
expect(manager.isBannerVisible()).toBe(true);
});
it('should hide banner', async () => {
await manager.init();
manager.hideBanner();
expect(manager.isBannerVisible()).toBe(false);
});
it('should emit banner_show event', async () => {
const callback = vi.fn();
manager.on('banner_show', callback);
await manager.init(); // This shows banner
expect(callback).toHaveBeenCalled();
});
it('should emit banner_hide event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('banner_hide', callback);
manager.hideBanner();
expect(callback).toHaveBeenCalled();
});
it('should not show banner if already visible', async () => {
await manager.init();
const callback = vi.fn();
manager.on('banner_show', callback);
callback.mockClear();
manager.showBanner();
manager.showBanner();
expect(callback).toHaveBeenCalledTimes(0); // Already visible from init
});
});
describe('showSettings', () => {
it('should emit settings_open event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('settings_open', callback);
manager.showSettings();
expect(callback).toHaveBeenCalled();
});
});
describe('event handling', () => {
it('should register event listeners', async () => {
await manager.init();
const callback = vi.fn();
manager.on('change', callback);
await manager.acceptAll();
expect(callback).toHaveBeenCalled();
});
it('should unregister event listeners', async () => {
await manager.init();
const callback = vi.fn();
manager.on('change', callback);
manager.off('change', callback);
await manager.acceptAll();
expect(callback).not.toHaveBeenCalled();
});
it('should return unsubscribe function', async () => {
await manager.init();
const callback = vi.fn();
const unsubscribe = manager.on('change', callback);
unsubscribe();
await manager.acceptAll();
expect(callback).not.toHaveBeenCalled();
});
});
describe('callbacks', () => {
it('should call onConsentChange callback', async () => {
const onConsentChange = vi.fn();
manager = new ConsentManager({
...mockConfig,
onConsentChange,
});
await manager.init();
await manager.acceptAll();
expect(onConsentChange).toHaveBeenCalled();
});
it('should call onBannerShow callback', async () => {
const onBannerShow = vi.fn();
manager = new ConsentManager({
...mockConfig,
onBannerShow,
});
await manager.init();
expect(onBannerShow).toHaveBeenCalled();
});
it('should call onBannerHide callback', async () => {
const onBannerHide = vi.fn();
manager = new ConsentManager({
...mockConfig,
onBannerHide,
});
await manager.init();
manager.hideBanner();
expect(onBannerHide).toHaveBeenCalled();
});
});
describe('static methods', () => {
it('should return SDK version', () => {
const version = ConsentManager.getVersion();
expect(version).toBeDefined();
expect(typeof version).toBe('string');
});
});
describe('Google Consent Mode', () => {
it('should update Google Consent Mode when gtag is available', async () => {
const gtag = vi.fn();
(window as unknown as { gtag: typeof gtag }).gtag = gtag;
await manager.init();
await manager.acceptAll();
expect(gtag).toHaveBeenCalledWith(
'consent',
'update',
expect.objectContaining({
analytics_storage: 'granted',
ad_storage: 'granted',
})
);
delete (window as unknown as { gtag?: typeof gtag }).gtag;
});
});
describe('consent expiration', () => {
it('should clear expired consent on init', async () => {
const storageKey = `bp_consent_${mockConfig.siteId}`;
const expiredConsent = {
categories: {
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
},
vendors: {},
timestamp: '2020-01-01T00:00:00.000Z', // Very old
version: '1.0.0',
expiresAt: '2020-06-01T00:00:00.000Z', // Expired
};
const data = JSON.stringify(expiredConsent) + mockConfig.siteId;
let hash = 5381;
for (let i = 0; i < data.length; i++) {
hash = (hash * 33) ^ data.charCodeAt(i);
}
const signature = (hash >>> 0).toString(16);
localStorage.setItem(
storageKey,
JSON.stringify({
version: '1',
consent: expiredConsent,
signature,
})
);
manager = new ConsentManager(mockConfig);
await manager.init();
expect(manager.needsConsent()).toBe(true);
});
});
describe('debug mode', () => {
it('should log when debug is enabled', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugManager = new ConsentManager({
...mockConfig,
debug: true,
});
await debugManager.init();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('consent input normalization', () => {
it('should accept categories object directly', async () => {
await manager.init();
await manager.setConsent({
essential: true,
functional: true,
analytics: false,
marketing: false,
social: false,
});
expect(manager.hasConsent('functional')).toBe(true);
});
it('should accept nested categories object', async () => {
await manager.init();
await manager.setConsent({
categories: {
essential: true,
functional: false,
analytics: true,
marketing: false,
social: false,
},
});
expect(manager.hasConsent('analytics')).toBe(true);
expect(manager.hasConsent('functional')).toBe(false);
});
it('should accept vendors in consent input', async () => {
await manager.init();
await manager.setConsent({
categories: {
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
},
vendors: {
'google-analytics': true,
},
});
const consent = manager.getConsent();
expect(consent?.vendors['google-analytics']).toBe(true);
});
});
});

View File

@@ -0,0 +1,525 @@
/**
* ConsentManager - Hauptklasse fuer das Consent Management
*
* DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile.
*/
import type {
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
ConsentInput,
ConsentEventType,
ConsentEventCallback,
ConsentEventData,
} from '../types';
import { ConsentStorage } from './ConsentStorage';
import { ScriptBlocker } from './ScriptBlocker';
import { ConsentAPI } from './ConsentAPI';
import { EventEmitter } from '../utils/EventEmitter';
import { generateFingerprint } from '../utils/fingerprint';
import { SDK_VERSION } from '../version';
/**
* Default-Konfiguration
*/
const DEFAULT_CONFIG: Partial<ConsentConfig> = {
language: 'de',
fallbackLanguage: 'en',
ui: {
position: 'bottom',
layout: 'modal',
theme: 'auto',
zIndex: 999999,
blockScrollOnModal: true,
},
consent: {
required: true,
rejectAllVisible: true,
acceptAllVisible: true,
granularControl: true,
vendorControl: false,
rememberChoice: true,
rememberDays: 365,
geoTargeting: false,
recheckAfterDays: 180,
},
categories: ['essential', 'functional', 'analytics', 'marketing', 'social'],
debug: false,
};
/**
* Default Consent-State (nur Essential aktiv)
*/
const DEFAULT_CONSENT: ConsentCategories = {
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
/**
* ConsentManager - Zentrale Klasse fuer Consent-Verwaltung
*/
export class ConsentManager {
private config: ConsentConfig;
private storage: ConsentStorage;
private scriptBlocker: ScriptBlocker;
private api: ConsentAPI;
private events: EventEmitter<ConsentEventData>;
private currentConsent: ConsentState | null = null;
private initialized = false;
private bannerVisible = false;
private deviceFingerprint: string = '';
constructor(config: ConsentConfig) {
this.config = this.mergeConfig(config);
this.storage = new ConsentStorage(this.config);
this.scriptBlocker = new ScriptBlocker(this.config);
this.api = new ConsentAPI(this.config);
this.events = new EventEmitter();
this.log('ConsentManager created with config:', this.config);
}
/**
* SDK initialisieren
*/
async init(): Promise<void> {
if (this.initialized) {
this.log('Already initialized, skipping');
return;
}
try {
this.log('Initializing ConsentManager...');
// Device Fingerprint generieren
this.deviceFingerprint = await generateFingerprint();
// Consent aus Storage laden
this.currentConsent = this.storage.get();
if (this.currentConsent) {
this.log('Loaded consent from storage:', this.currentConsent);
// Pruefen ob Consent abgelaufen
if (this.isConsentExpired()) {
this.log('Consent expired, clearing');
this.storage.clear();
this.currentConsent = null;
} else {
// Consent anwenden
this.applyConsent();
}
}
// Script-Blocker initialisieren
this.scriptBlocker.init();
this.initialized = true;
this.emit('init', this.currentConsent);
// Banner anzeigen falls noetig
if (this.needsConsent()) {
this.showBanner();
}
this.log('ConsentManager initialized successfully');
} catch (error) {
this.handleError(error as Error);
throw error;
}
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Pruefen ob Consent fuer Kategorie vorhanden
*/
hasConsent(category: ConsentCategory): boolean {
if (!this.currentConsent) {
return category === 'essential';
}
return this.currentConsent.categories[category] ?? false;
}
/**
* Pruefen ob Consent fuer Vendor vorhanden
*/
hasVendorConsent(vendorId: string): boolean {
if (!this.currentConsent) {
return false;
}
return this.currentConsent.vendors[vendorId] ?? false;
}
/**
* Aktuellen Consent-State abrufen
*/
getConsent(): ConsentState | null {
return this.currentConsent ? { ...this.currentConsent } : null;
}
/**
* Consent setzen
*/
async setConsent(input: ConsentInput): Promise<void> {
const categories = this.normalizeConsentInput(input);
// Essential ist immer aktiv
categories.essential = true;
const newConsent: ConsentState = {
categories,
vendors: 'vendors' in input && input.vendors ? input.vendors : {},
timestamp: new Date().toISOString(),
version: SDK_VERSION,
};
try {
// An Backend senden
const response = await this.api.saveConsent({
siteId: this.config.siteId,
deviceFingerprint: this.deviceFingerprint,
consent: newConsent,
});
newConsent.consentId = response.consentId;
newConsent.expiresAt = response.expiresAt;
// Lokal speichern
this.storage.set(newConsent);
this.currentConsent = newConsent;
// Consent anwenden
this.applyConsent();
// Event emittieren
this.emit('change', newConsent);
this.config.onConsentChange?.(newConsent);
this.log('Consent saved:', newConsent);
} catch (error) {
// Bei Netzwerkfehler trotzdem lokal speichern
this.log('API error, saving locally:', error);
this.storage.set(newConsent);
this.currentConsent = newConsent;
this.applyConsent();
this.emit('change', newConsent);
}
}
/**
* Alle Kategorien akzeptieren
*/
async acceptAll(): Promise<void> {
const allCategories: ConsentCategories = {
essential: true,
functional: true,
analytics: true,
marketing: true,
social: true,
};
await this.setConsent(allCategories);
this.emit('accept_all', this.currentConsent!);
this.hideBanner();
}
/**
* Alle nicht-essentiellen Kategorien ablehnen
*/
async rejectAll(): Promise<void> {
const minimalCategories: ConsentCategories = {
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
await this.setConsent(minimalCategories);
this.emit('reject_all', this.currentConsent!);
this.hideBanner();
}
/**
* Alle Einwilligungen widerrufen
*/
async revokeAll(): Promise<void> {
if (this.currentConsent?.consentId) {
try {
await this.api.revokeConsent(this.currentConsent.consentId);
} catch (error) {
this.log('Failed to revoke on server:', error);
}
}
this.storage.clear();
this.currentConsent = null;
this.scriptBlocker.blockAll();
this.log('All consents revoked');
}
/**
* Consent-Daten exportieren (DSGVO Art. 20)
*/
async exportConsent(): Promise<string> {
const exportData = {
currentConsent: this.currentConsent,
exportedAt: new Date().toISOString(),
siteId: this.config.siteId,
deviceFingerprint: this.deviceFingerprint,
};
return JSON.stringify(exportData, null, 2);
}
// ===========================================================================
// Banner Control
// ===========================================================================
/**
* Pruefen ob Consent-Abfrage noetig
*/
needsConsent(): boolean {
if (!this.currentConsent) {
return true;
}
if (this.isConsentExpired()) {
return true;
}
// Recheck nach X Tagen
if (this.config.consent?.recheckAfterDays) {
const consentDate = new Date(this.currentConsent.timestamp);
const recheckDate = new Date(consentDate);
recheckDate.setDate(
recheckDate.getDate() + this.config.consent.recheckAfterDays
);
if (new Date() > recheckDate) {
return true;
}
}
return false;
}
/**
* Banner anzeigen
*/
showBanner(): void {
if (this.bannerVisible) {
return;
}
this.bannerVisible = true;
this.emit('banner_show', undefined);
this.config.onBannerShow?.();
// Banner wird von UI-Komponente gerendert
// Hier nur Status setzen
this.log('Banner shown');
}
/**
* Banner verstecken
*/
hideBanner(): void {
if (!this.bannerVisible) {
return;
}
this.bannerVisible = false;
this.emit('banner_hide', undefined);
this.config.onBannerHide?.();
this.log('Banner hidden');
}
/**
* Einstellungs-Modal oeffnen
*/
showSettings(): void {
this.emit('settings_open', undefined);
this.log('Settings opened');
}
/**
* Pruefen ob Banner sichtbar
*/
isBannerVisible(): boolean {
return this.bannerVisible;
}
// ===========================================================================
// Event Handling
// ===========================================================================
/**
* Event-Listener registrieren
*/
on<T extends ConsentEventType>(
event: T,
callback: ConsentEventCallback<ConsentEventData[T]>
): () => void {
return this.events.on(event, callback);
}
/**
* Event-Listener entfernen
*/
off<T extends ConsentEventType>(
event: T,
callback: ConsentEventCallback<ConsentEventData[T]>
): void {
this.events.off(event, callback);
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Konfiguration zusammenfuehren
*/
private mergeConfig(config: ConsentConfig): ConsentConfig {
return {
...DEFAULT_CONFIG,
...config,
ui: { ...DEFAULT_CONFIG.ui, ...config.ui },
consent: { ...DEFAULT_CONFIG.consent, ...config.consent },
} as ConsentConfig;
}
/**
* Consent-Input normalisieren
*/
private normalizeConsentInput(input: ConsentInput): ConsentCategories {
if ('categories' in input && input.categories) {
return { ...DEFAULT_CONSENT, ...input.categories };
}
return { ...DEFAULT_CONSENT, ...(input as Partial<ConsentCategories>) };
}
/**
* Consent anwenden (Skripte aktivieren/blockieren)
*/
private applyConsent(): void {
if (!this.currentConsent) {
return;
}
for (const [category, allowed] of Object.entries(
this.currentConsent.categories
)) {
if (allowed) {
this.scriptBlocker.enableCategory(category as ConsentCategory);
} else {
this.scriptBlocker.disableCategory(category as ConsentCategory);
}
}
// Google Consent Mode aktualisieren
this.updateGoogleConsentMode();
}
/**
* Google Consent Mode v2 aktualisieren
*/
private updateGoogleConsentMode(): void {
if (typeof window === 'undefined' || !this.currentConsent) {
return;
}
const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag;
if (typeof gtag !== 'function') {
return;
}
const { categories } = this.currentConsent;
gtag('consent', 'update', {
ad_storage: categories.marketing ? 'granted' : 'denied',
ad_user_data: categories.marketing ? 'granted' : 'denied',
ad_personalization: categories.marketing ? 'granted' : 'denied',
analytics_storage: categories.analytics ? 'granted' : 'denied',
functionality_storage: categories.functional ? 'granted' : 'denied',
personalization_storage: categories.functional ? 'granted' : 'denied',
security_storage: 'granted',
});
this.log('Google Consent Mode updated');
}
/**
* Pruefen ob Consent abgelaufen
*/
private isConsentExpired(): boolean {
if (!this.currentConsent?.expiresAt) {
// Fallback: Nach rememberDays ablaufen
if (this.currentConsent?.timestamp && this.config.consent?.rememberDays) {
const consentDate = new Date(this.currentConsent.timestamp);
const expiryDate = new Date(consentDate);
expiryDate.setDate(
expiryDate.getDate() + this.config.consent.rememberDays
);
return new Date() > expiryDate;
}
return false;
}
return new Date() > new Date(this.currentConsent.expiresAt);
}
/**
* Event emittieren
*/
private emit<T extends ConsentEventType>(
event: T,
data: ConsentEventData[T]
): void {
this.events.emit(event, data);
}
/**
* Fehler behandeln
*/
private handleError(error: Error): void {
this.log('Error:', error);
this.emit('error', error);
this.config.onError?.(error);
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ConsentSDK]', ...args);
}
}
// ===========================================================================
// Static Methods
// ===========================================================================
/**
* SDK-Version abrufen
*/
static getVersion(): string {
return SDK_VERSION;
}
}
// Default-Export
export default ConsentManager;

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConsentStorage } from './ConsentStorage';
import type { ConsentConfig, ConsentState } from '../types';
describe('ConsentStorage', () => {
let storage: ConsentStorage;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com',
siteId: 'test-site',
debug: false,
consent: {
rememberDays: 365,
},
};
const mockConsent: ConsentState = {
categories: {
essential: true,
functional: true,
analytics: false,
marketing: false,
social: false,
},
vendors: {},
timestamp: '2024-01-15T10:00:00.000Z',
version: '1.0.0',
};
beforeEach(() => {
localStorage.clear();
storage = new ConsentStorage(mockConfig);
});
describe('constructor', () => {
it('should create storage with site-specific key', () => {
expect(storage).toBeDefined();
});
});
describe('get', () => {
it('should return null when no consent stored', () => {
const result = storage.get();
expect(result).toBeNull();
});
it('should return consent when valid data exists', () => {
storage.set(mockConsent);
const result = storage.get();
expect(result).toBeDefined();
expect(result?.categories.essential).toBe(true);
expect(result?.categories.analytics).toBe(false);
});
it('should return null and clear when version mismatch', () => {
// Manually set invalid version in storage
const storageKey = `bp_consent_${mockConfig.siteId}`;
localStorage.setItem(
storageKey,
JSON.stringify({
version: 'invalid',
consent: mockConsent,
signature: 'test',
})
);
const result = storage.get();
expect(result).toBeNull();
});
it('should return null and clear when signature invalid', () => {
const storageKey = `bp_consent_${mockConfig.siteId}`;
localStorage.setItem(
storageKey,
JSON.stringify({
version: '1',
consent: mockConsent,
signature: 'invalid-signature',
})
);
const result = storage.get();
expect(result).toBeNull();
});
it('should return null when JSON is invalid', () => {
const storageKey = `bp_consent_${mockConfig.siteId}`;
localStorage.setItem(storageKey, 'invalid-json');
const result = storage.get();
expect(result).toBeNull();
});
});
describe('set', () => {
it('should store consent in localStorage', () => {
storage.set(mockConsent);
const storageKey = `bp_consent_${mockConfig.siteId}`;
const stored = localStorage.getItem(storageKey);
expect(stored).toBeDefined();
expect(stored).toContain('"version":"1"');
});
it('should set a cookie for SSR support', () => {
storage.set(mockConsent);
expect(document.cookie).toContain(`bp_consent_${mockConfig.siteId}`);
});
it('should include signature in stored data', () => {
storage.set(mockConsent);
const storageKey = `bp_consent_${mockConfig.siteId}`;
const stored = JSON.parse(localStorage.getItem(storageKey) || '{}');
expect(stored.signature).toBeDefined();
expect(typeof stored.signature).toBe('string');
});
});
describe('clear', () => {
it('should remove consent from localStorage', () => {
storage.set(mockConsent);
storage.clear();
const result = storage.get();
expect(result).toBeNull();
});
it('should clear the cookie', () => {
storage.set(mockConsent);
storage.clear();
// Cookie should be cleared (expired)
expect(document.cookie).toContain('expires=');
});
});
describe('exists', () => {
it('should return false when no consent exists', () => {
expect(storage.exists()).toBe(false);
});
it('should return true when consent exists', () => {
storage.set(mockConsent);
expect(storage.exists()).toBe(true);
});
});
describe('signature verification', () => {
it('should detect tampered consent data', () => {
storage.set(mockConsent);
const storageKey = `bp_consent_${mockConfig.siteId}`;
const stored = JSON.parse(localStorage.getItem(storageKey) || '{}');
// Tamper with the data
stored.consent.categories.marketing = true;
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = storage.get();
expect(result).toBeNull(); // Signature mismatch should clear
});
});
describe('debug mode', () => {
it('should log when debug is enabled', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugStorage = new ConsentStorage({
...mockConfig,
debug: true,
});
debugStorage.set(mockConsent);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('cookie settings', () => {
it('should set Secure flag on HTTPS', () => {
storage.set(mockConsent);
expect(document.cookie).toContain('Secure');
});
it('should set SameSite=Lax', () => {
storage.set(mockConsent);
expect(document.cookie).toContain('SameSite=Lax');
});
it('should set expiration based on rememberDays', () => {
storage.set(mockConsent);
expect(document.cookie).toContain('expires=');
});
});
describe('different sites', () => {
it('should isolate storage by siteId', () => {
const storage1 = new ConsentStorage({ ...mockConfig, siteId: 'site-1' });
const storage2 = new ConsentStorage({ ...mockConfig, siteId: 'site-2' });
storage1.set(mockConsent);
expect(storage1.exists()).toBe(true);
expect(storage2.exists()).toBe(false);
});
});
});

View File

@@ -0,0 +1,203 @@
/**
* ConsentStorage - Lokale Speicherung des Consent-Status
*
* Speichert Consent-Daten im localStorage mit HMAC-Signatur
* zur Manipulationserkennung.
*/
import type { ConsentConfig, ConsentState } from '../types';
const STORAGE_KEY = 'bp_consent';
const STORAGE_VERSION = '1';
/**
* Gespeichertes Format
*/
interface StoredConsent {
version: string;
consent: ConsentState;
signature: string;
}
/**
* ConsentStorage - Persistente Speicherung
*/
export class ConsentStorage {
private config: ConsentConfig;
private storageKey: string;
constructor(config: ConsentConfig) {
this.config = config;
// Pro Site ein separater Key
this.storageKey = `${STORAGE_KEY}_${config.siteId}`;
}
/**
* Consent laden
*/
get(): ConsentState | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = localStorage.getItem(this.storageKey);
if (!raw) {
return null;
}
const stored: StoredConsent = JSON.parse(raw);
// Version pruefen
if (stored.version !== STORAGE_VERSION) {
this.log('Storage version mismatch, clearing');
this.clear();
return null;
}
// Signatur pruefen
if (!this.verifySignature(stored.consent, stored.signature)) {
this.log('Invalid signature, clearing');
this.clear();
return null;
}
return stored.consent;
} catch (error) {
this.log('Failed to load consent:', error);
return null;
}
}
/**
* Consent speichern
*/
set(consent: ConsentState): void {
if (typeof window === 'undefined') {
return;
}
try {
const signature = this.generateSignature(consent);
const stored: StoredConsent = {
version: STORAGE_VERSION,
consent,
signature,
};
localStorage.setItem(this.storageKey, JSON.stringify(stored));
// Auch als Cookie setzen (fuer Server-Side Rendering)
this.setCookie(consent);
this.log('Consent saved to storage');
} catch (error) {
this.log('Failed to save consent:', error);
}
}
/**
* Consent loeschen
*/
clear(): void {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.removeItem(this.storageKey);
this.clearCookie();
this.log('Consent cleared from storage');
} catch (error) {
this.log('Failed to clear consent:', error);
}
}
/**
* Pruefen ob Consent existiert
*/
exists(): boolean {
return this.get() !== null;
}
// ===========================================================================
// Cookie Management
// ===========================================================================
/**
* Consent als Cookie setzen
*/
private setCookie(consent: ConsentState): void {
const days = this.config.consent?.rememberDays ?? 365;
const expires = new Date();
expires.setDate(expires.getDate() + days);
// Nur Kategorien als Cookie (fuer SSR)
const cookieValue = JSON.stringify(consent.categories);
const encoded = encodeURIComponent(cookieValue);
document.cookie = [
`${this.storageKey}=${encoded}`,
`expires=${expires.toUTCString()}`,
'path=/',
'SameSite=Lax',
location.protocol === 'https:' ? 'Secure' : '',
]
.filter(Boolean)
.join('; ');
}
/**
* Cookie loeschen
*/
private clearCookie(): void {
document.cookie = `${this.storageKey}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
// ===========================================================================
// Signature (Simple HMAC-like)
// ===========================================================================
/**
* Signatur generieren
*/
private generateSignature(consent: ConsentState): string {
const data = JSON.stringify(consent);
const key = this.config.siteId;
// Einfache Hash-Funktion (fuer Client-Side)
// In Produktion wuerde man SubtleCrypto verwenden
return this.simpleHash(data + key);
}
/**
* Signatur verifizieren
*/
private verifySignature(consent: ConsentState, signature: string): boolean {
const expected = this.generateSignature(consent);
return expected === signature;
}
/**
* Einfache Hash-Funktion (djb2)
*/
private simpleHash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ConsentStorage]', ...args);
}
}
}
export default ConsentStorage;

View File

@@ -0,0 +1,305 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ScriptBlocker } from './ScriptBlocker';
import type { ConsentConfig } from '../types';
describe('ScriptBlocker', () => {
let blocker: ScriptBlocker;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com',
siteId: 'test-site',
debug: false,
};
beforeEach(() => {
// Clear document body
document.body.innerHTML = '';
blocker = new ScriptBlocker(mockConfig);
});
afterEach(() => {
blocker.destroy();
});
describe('constructor', () => {
it('should create blocker with essential category enabled by default', () => {
expect(blocker.isCategoryEnabled('essential')).toBe(true);
});
it('should have other categories disabled by default', () => {
expect(blocker.isCategoryEnabled('analytics')).toBe(false);
expect(blocker.isCategoryEnabled('marketing')).toBe(false);
expect(blocker.isCategoryEnabled('functional')).toBe(false);
expect(blocker.isCategoryEnabled('social')).toBe(false);
});
});
describe('init', () => {
it('should process existing scripts with data-consent', () => {
// Add a blocked script before init
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
script.setAttribute('data-src', 'https://analytics.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
blocker.init();
// Script should remain blocked (analytics not enabled)
expect(script.type).toBe('text/plain');
});
it('should start MutationObserver for new elements', () => {
blocker.init();
// Add a script after init
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
script.setAttribute('data-src', 'https://analytics.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
// Script should be tracked (processed)
expect(script.type).toBe('text/plain');
});
});
describe('enableCategory', () => {
it('should enable a category', () => {
blocker.enableCategory('analytics');
expect(blocker.isCategoryEnabled('analytics')).toBe(true);
});
it('should not duplicate enabling', () => {
blocker.enableCategory('analytics');
blocker.enableCategory('analytics');
expect(blocker.isCategoryEnabled('analytics')).toBe(true);
});
it('should activate blocked scripts for the category', () => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
script.setAttribute('data-src', 'https://analytics.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
blocker.init();
blocker.enableCategory('analytics');
// The original script should be replaced
const scripts = document.querySelectorAll('script[src="https://analytics.example.com/script.js"]');
expect(scripts.length).toBe(1);
});
});
describe('disableCategory', () => {
it('should disable a category', () => {
blocker.enableCategory('analytics');
blocker.disableCategory('analytics');
expect(blocker.isCategoryEnabled('analytics')).toBe(false);
});
it('should not disable essential category', () => {
blocker.disableCategory('essential');
expect(blocker.isCategoryEnabled('essential')).toBe(true);
});
});
describe('blockAll', () => {
it('should block all categories except essential', () => {
blocker.enableCategory('analytics');
blocker.enableCategory('marketing');
blocker.enableCategory('social');
blocker.blockAll();
expect(blocker.isCategoryEnabled('essential')).toBe(true);
expect(blocker.isCategoryEnabled('analytics')).toBe(false);
expect(blocker.isCategoryEnabled('marketing')).toBe(false);
expect(blocker.isCategoryEnabled('social')).toBe(false);
});
});
describe('isCategoryEnabled', () => {
it('should return true for enabled categories', () => {
blocker.enableCategory('functional');
expect(blocker.isCategoryEnabled('functional')).toBe(true);
});
it('should return false for disabled categories', () => {
expect(blocker.isCategoryEnabled('marketing')).toBe(false);
});
});
describe('destroy', () => {
it('should disconnect the MutationObserver', () => {
blocker.init();
blocker.destroy();
// After destroy, adding new elements should not trigger processing
// This is hard to test directly, but we can verify it doesn't throw
expect(() => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
document.body.appendChild(script);
}).not.toThrow();
});
});
describe('script processing', () => {
it('should handle external scripts with data-src', () => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'essential');
script.setAttribute('data-src', 'https://essential.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
blocker.init();
// Essential is enabled, so script should be activated
const activatedScript = document.querySelector('script[src="https://essential.example.com/script.js"]');
expect(activatedScript).toBeDefined();
});
it('should handle inline scripts', () => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'essential');
script.type = 'text/plain';
script.textContent = 'console.log("test");';
document.body.appendChild(script);
blocker.init();
// Check that script was processed
const scripts = document.querySelectorAll('script');
expect(scripts.length).toBeGreaterThan(0);
});
});
describe('iframe processing', () => {
it('should block iframes with data-consent', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
// Should be hidden and have placeholder
expect(iframe.style.display).toBe('none');
});
it('should show placeholder for blocked iframes', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
const placeholder = document.querySelector('.bp-consent-placeholder');
expect(placeholder).toBeDefined();
});
it('should activate iframe when category enabled', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
blocker.enableCategory('social');
expect(iframe.src).toBe('https://social.example.com/embed');
expect(iframe.style.display).toBe('');
});
it('should remove placeholder when iframe activated', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
blocker.enableCategory('social');
const placeholder = document.querySelector('.bp-consent-placeholder');
expect(placeholder).toBeNull();
});
});
describe('placeholder button', () => {
it('should dispatch event when placeholder button clicked', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'marketing');
iframe.setAttribute('data-src', 'https://marketing.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
const eventHandler = vi.fn();
window.addEventListener('bp-consent-request', eventHandler);
const button = document.querySelector('.bp-consent-placeholder-btn') as HTMLButtonElement;
button?.click();
expect(eventHandler).toHaveBeenCalled();
expect(eventHandler.mock.calls[0][0].detail.category).toBe('marketing');
window.removeEventListener('bp-consent-request', eventHandler);
});
});
describe('category names', () => {
it('should show correct category name in placeholder', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'analytics');
iframe.setAttribute('data-src', 'https://analytics.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
const placeholder = document.querySelector('.bp-consent-placeholder');
expect(placeholder?.innerHTML).toContain('Statistik-Cookies aktivieren');
});
});
describe('debug mode', () => {
it('should log when debug is enabled', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugBlocker = new ScriptBlocker({
...mockConfig,
debug: true,
});
debugBlocker.init();
debugBlocker.enableCategory('analytics');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
debugBlocker.destroy();
});
});
describe('nested elements', () => {
it('should process scripts in nested containers', () => {
const container = document.createElement('div');
const script = document.createElement('script');
script.setAttribute('data-consent', 'essential');
script.setAttribute('data-src', 'https://essential.example.com/nested.js');
script.type = 'text/plain';
container.appendChild(script);
blocker.init();
document.body.appendChild(container);
// Give MutationObserver time to process
const activatedScript = document.querySelector('script[src="https://essential.example.com/nested.js"]');
expect(activatedScript).toBeDefined();
});
});
});

View File

@@ -0,0 +1,367 @@
/**
* ScriptBlocker - Blockiert Skripte bis Consent erteilt wird
*
* Verwendet das data-consent Attribut zur Identifikation von
* Skripten, die erst nach Consent geladen werden duerfen.
*
* Beispiel:
* <script data-consent="analytics" data-src="..." type="text/plain"></script>
*/
import type { ConsentConfig, ConsentCategory } from '../types';
/**
* Script-Element mit Consent-Attributen
*/
interface ConsentScript extends HTMLScriptElement {
dataset: DOMStringMap & {
consent?: string;
src?: string;
};
}
/**
* iFrame-Element mit Consent-Attributen
*/
interface ConsentIframe extends HTMLIFrameElement {
dataset: DOMStringMap & {
consent?: string;
src?: string;
};
}
/**
* ScriptBlocker - Verwaltet Script-Blocking
*/
export class ScriptBlocker {
private config: ConsentConfig;
private observer: MutationObserver | null = null;
private enabledCategories: Set<ConsentCategory> = new Set(['essential']);
private processedElements: WeakSet<Element> = new WeakSet();
constructor(config: ConsentConfig) {
this.config = config;
}
/**
* Initialisieren und Observer starten
*/
init(): void {
if (typeof window === 'undefined') {
return;
}
// Bestehende Elemente verarbeiten
this.processExistingElements();
// MutationObserver fuer neue Elemente
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.processElement(node as Element);
}
}
}
});
this.observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
this.log('ScriptBlocker initialized');
}
/**
* Kategorie aktivieren
*/
enableCategory(category: ConsentCategory): void {
if (this.enabledCategories.has(category)) {
return;
}
this.enabledCategories.add(category);
this.log('Category enabled:', category);
// Blockierte Elemente dieser Kategorie aktivieren
this.activateCategory(category);
}
/**
* Kategorie deaktivieren
*/
disableCategory(category: ConsentCategory): void {
if (category === 'essential') {
// Essential kann nicht deaktiviert werden
return;
}
this.enabledCategories.delete(category);
this.log('Category disabled:', category);
// Hinweis: Bereits geladene Skripte koennen nicht entladen werden
// Page-Reload noetig fuer vollstaendige Deaktivierung
}
/**
* Alle Kategorien blockieren (ausser Essential)
*/
blockAll(): void {
this.enabledCategories.clear();
this.enabledCategories.add('essential');
this.log('All categories blocked');
}
/**
* Pruefen ob Kategorie aktiviert
*/
isCategoryEnabled(category: ConsentCategory): boolean {
return this.enabledCategories.has(category);
}
/**
* Observer stoppen
*/
destroy(): void {
this.observer?.disconnect();
this.observer = null;
this.log('ScriptBlocker destroyed');
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Bestehende Elemente verarbeiten
*/
private processExistingElements(): void {
// Scripts mit data-consent
const scripts = document.querySelectorAll<ConsentScript>(
'script[data-consent]'
);
scripts.forEach((script) => this.processScript(script));
// iFrames mit data-consent
const iframes = document.querySelectorAll<ConsentIframe>(
'iframe[data-consent]'
);
iframes.forEach((iframe) => this.processIframe(iframe));
this.log(`Processed ${scripts.length} scripts, ${iframes.length} iframes`);
}
/**
* Element verarbeiten
*/
private processElement(element: Element): void {
if (element.tagName === 'SCRIPT') {
this.processScript(element as ConsentScript);
} else if (element.tagName === 'IFRAME') {
this.processIframe(element as ConsentIframe);
}
// Auch Kinder verarbeiten
element
.querySelectorAll<ConsentScript>('script[data-consent]')
.forEach((script) => this.processScript(script));
element
.querySelectorAll<ConsentIframe>('iframe[data-consent]')
.forEach((iframe) => this.processIframe(iframe));
}
/**
* Script-Element verarbeiten
*/
private processScript(script: ConsentScript): void {
if (this.processedElements.has(script)) {
return;
}
const category = script.dataset.consent as ConsentCategory | undefined;
if (!category) {
return;
}
this.processedElements.add(script);
if (this.enabledCategories.has(category)) {
this.activateScript(script);
} else {
this.log(`Script blocked (${category}):`, script.dataset.src || 'inline');
}
}
/**
* iFrame-Element verarbeiten
*/
private processIframe(iframe: ConsentIframe): void {
if (this.processedElements.has(iframe)) {
return;
}
const category = iframe.dataset.consent as ConsentCategory | undefined;
if (!category) {
return;
}
this.processedElements.add(iframe);
if (this.enabledCategories.has(category)) {
this.activateIframe(iframe);
} else {
this.log(`iFrame blocked (${category}):`, iframe.dataset.src);
// Placeholder anzeigen
this.showPlaceholder(iframe, category);
}
}
/**
* Script aktivieren
*/
private activateScript(script: ConsentScript): void {
const src = script.dataset.src;
if (src) {
// Externes Script: neues Element erstellen
const newScript = document.createElement('script');
// Attribute kopieren
for (const attr of script.attributes) {
if (attr.name !== 'type' && attr.name !== 'data-src') {
newScript.setAttribute(attr.name, attr.value);
}
}
newScript.src = src;
newScript.removeAttribute('data-consent');
// Altes Element ersetzen
script.parentNode?.replaceChild(newScript, script);
this.log('External script activated:', src);
} else {
// Inline-Script: type aendern
const newScript = document.createElement('script');
for (const attr of script.attributes) {
if (attr.name !== 'type') {
newScript.setAttribute(attr.name, attr.value);
}
}
newScript.textContent = script.textContent;
newScript.removeAttribute('data-consent');
script.parentNode?.replaceChild(newScript, script);
this.log('Inline script activated');
}
}
/**
* iFrame aktivieren
*/
private activateIframe(iframe: ConsentIframe): void {
const src = iframe.dataset.src;
if (!src) {
return;
}
// Placeholder entfernen falls vorhanden
const placeholder = iframe.parentElement?.querySelector(
'.bp-consent-placeholder'
);
placeholder?.remove();
// src setzen
iframe.src = src;
iframe.removeAttribute('data-src');
iframe.removeAttribute('data-consent');
iframe.style.display = '';
this.log('iFrame activated:', src);
}
/**
* Placeholder fuer blockierten iFrame anzeigen
*/
private showPlaceholder(iframe: ConsentIframe, category: ConsentCategory): void {
// iFrame verstecken
iframe.style.display = 'none';
// Placeholder erstellen
const placeholder = document.createElement('div');
placeholder.className = 'bp-consent-placeholder';
placeholder.setAttribute('data-category', category);
placeholder.innerHTML = `
<div class="bp-consent-placeholder-content">
<p>Dieser Inhalt erfordert Ihre Zustimmung.</p>
<button type="button" class="bp-consent-placeholder-btn">
${this.getCategoryName(category)} aktivieren
</button>
</div>
`;
// Click-Handler
const btn = placeholder.querySelector('button');
btn?.addEventListener('click', () => {
// Event dispatchen damit ConsentManager reagieren kann
window.dispatchEvent(
new CustomEvent('bp-consent-request', {
detail: { category },
})
);
});
// Nach iFrame einfuegen
iframe.parentNode?.insertBefore(placeholder, iframe.nextSibling);
}
/**
* Alle Elemente einer Kategorie aktivieren
*/
private activateCategory(category: ConsentCategory): void {
// Scripts
const scripts = document.querySelectorAll<ConsentScript>(
`script[data-consent="${category}"]`
);
scripts.forEach((script) => this.activateScript(script));
// iFrames
const iframes = document.querySelectorAll<ConsentIframe>(
`iframe[data-consent="${category}"]`
);
iframes.forEach((iframe) => this.activateIframe(iframe));
this.log(
`Activated ${scripts.length} scripts, ${iframes.length} iframes for ${category}`
);
}
/**
* Kategorie-Name fuer UI
*/
private getCategoryName(category: ConsentCategory): string {
const names: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
return names[category] ?? category;
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ScriptBlocker]', ...args);
}
}
}
export default ScriptBlocker;

View File

@@ -0,0 +1,7 @@
/**
* Core module exports
*/
export { ConsentManager } from './ConsentManager';
export { ConsentStorage } from './ConsentStorage';
export { ScriptBlocker } from './ScriptBlocker';
export { ConsentAPI } from './ConsentAPI';