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:
312
consent-sdk/src/core/ConsentAPI.test.ts
Normal file
312
consent-sdk/src/core/ConsentAPI.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
212
consent-sdk/src/core/ConsentAPI.ts
Normal file
212
consent-sdk/src/core/ConsentAPI.ts
Normal 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;
|
||||
605
consent-sdk/src/core/ConsentManager.test.ts
Normal file
605
consent-sdk/src/core/ConsentManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
525
consent-sdk/src/core/ConsentManager.ts
Normal file
525
consent-sdk/src/core/ConsentManager.ts
Normal 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;
|
||||
212
consent-sdk/src/core/ConsentStorage.test.ts
Normal file
212
consent-sdk/src/core/ConsentStorage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
consent-sdk/src/core/ConsentStorage.ts
Normal file
203
consent-sdk/src/core/ConsentStorage.ts
Normal 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;
|
||||
305
consent-sdk/src/core/ScriptBlocker.test.ts
Normal file
305
consent-sdk/src/core/ScriptBlocker.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
367
consent-sdk/src/core/ScriptBlocker.ts
Normal file
367
consent-sdk/src/core/ScriptBlocker.ts
Normal 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;
|
||||
7
consent-sdk/src/core/index.ts
Normal file
7
consent-sdk/src/core/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Core module exports
|
||||
*/
|
||||
export { ConsentManager } from './ConsentManager';
|
||||
export { ConsentStorage } from './ConsentStorage';
|
||||
export { ScriptBlocker } from './ScriptBlocker';
|
||||
export { ConsentAPI } from './ConsentAPI';
|
||||
Reference in New Issue
Block a user