diff --git a/docs/GDPR_STORAGE_CLASSES.md b/docs/GDPR_STORAGE_CLASSES.md new file mode 100644 index 00000000..bf066e61 --- /dev/null +++ b/docs/GDPR_STORAGE_CLASSES.md @@ -0,0 +1,64 @@ +# GDPR Storage Classes + +## Problem + +The cookie consent store (`src/lib/consent/store.ts`) already tracks which +consent categories (`necessary`, `analytics`, `functional`, `marketing`) a +user has accepted. Nothing, however, tied that decision to the actual +browser storage (`localStorage`, `sessionStorage`, cookies) the app writes. +A feature could write an analytics identifier to `localStorage` regardless +of the user's choice, and revoking a previously granted category didn't +clear anything that had already been written — both of which are GDPR +gaps (Art. 7(3): withdrawing consent must be as easy, and as effective, as +giving it). + +## Solution: Storage Classes + +`src/lib/consent/storageClasses.ts` introduces a small enforcement layer on +top of the existing consent store: + +- **Declare** which consent category a storage key belongs to via a + `StorageClassDescriptor { key, category, area }`. +- **Gate** reads/writes: `setClassifiedItem`/`getClassifiedItem` only touch + storage when the descriptor's category is currently consented to. +- **Purge**: `purgeDisallowedStorage()` removes any registered entry whose + category is no longer allowed. `enforceStorageClasses()` runs a purge + immediately and again on every consent change (via + `useConsentStore.subscribe`). + +### Usage + +```ts +import { setClassifiedItem, getClassifiedItem } from '@/lib/consent/storageClasses'; + +// Only written if the user has consented to "analytics". +setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, clientId); + +// Returns null if the category isn't consented to, even if a stale value +// is still physically present. +const clientId = getClassifiedItem('ga_client_id'); +``` + +Supported `area` values: `localStorage`, `sessionStorage`, `cookie`. + +### Enforcement + +`useStorageClassEnforcement()` is called once from +`src/components/consent/CookieConsentBanner.tsx`, which is always mounted +(see `RootProviders`). This means: + +- On app load, any storage left over from a category the user has since + revoked (e.g. across browser sessions) is purged immediately. +- Whenever the user changes their preferences in `CookiePreferencesModal` + (accept all / reject all / save custom preferences), any storage tied to + a category that's no longer allowed is purged automatically. + +### Testing + +- `src/lib/consent/__tests__/storageClasses.test.ts` — unit tests for + registration, gating, purging, and the enforcement subscription across + all three storage areas. +- `src/components/consent/__tests__/CookieConsentBanner.test.tsx` — verifies + the banner purges disallowed storage on mount. + +Run: `pnpm test src/lib/consent src/components/consent` diff --git a/src/components/consent/CookieConsentBanner.tsx b/src/components/consent/CookieConsentBanner.tsx index a39fa2de..7b7c0579 100644 --- a/src/components/consent/CookieConsentBanner.tsx +++ b/src/components/consent/CookieConsentBanner.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useConsentStore } from '@/lib/consent/store'; +import { useStorageClassEnforcement } from '@/lib/consent/storageClasses'; import { CookiePreferencesModal } from './CookiePreferencesModal'; /** @@ -16,6 +17,10 @@ export function CookieConsentBanner() { const rejectAll = useConsentStore((s) => s.rejectAll); const [showPreferences, setShowPreferences] = useState(false); + // Purge any storage classes the user isn't (or is no longer) consenting to. + // Runs for the lifetime of the app since this component is always mounted. + useStorageClassEnforcement(); + // Hide banner once a valid decision exists if (decided && isConsentValid()) return null; diff --git a/src/components/consent/__tests__/CookieConsentBanner.test.tsx b/src/components/consent/__tests__/CookieConsentBanner.test.tsx new file mode 100644 index 00000000..7d8bb645 --- /dev/null +++ b/src/components/consent/__tests__/CookieConsentBanner.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { CookieConsentBanner } from '../CookieConsentBanner'; +import { useConsentStore } from '@/lib/consent/store'; +import { createDefaultConsentState } from '@/lib/consent/types'; +import { clearStorageClassRegistry, setClassifiedItem } from '@/lib/consent/storageClasses'; + +beforeEach(() => { + localStorage.clear(); + useConsentStore.setState(createDefaultConsentState()); + clearStorageClassRegistry(); +}); + +describe('CookieConsentBanner storage class enforcement', () => { + it('purges storage for a category that was revoked before the banner mounted', () => { + useConsentStore.getState().acceptAll(); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123'); + useConsentStore.getState().rejectAll(); + expect(localStorage.getItem('ga_client_id')).toBe('123'); + + render(); + + expect(localStorage.getItem('ga_client_id')).toBeNull(); + }); + + it('still renders the banner normally while enforcing storage classes', () => { + render(); + expect(screen.getByRole('region', { name: /cookie consent/i })).toBeInTheDocument(); + }); +}); diff --git a/src/lib/consent/__tests__/storageClasses.test.ts b/src/lib/consent/__tests__/storageClasses.test.ts new file mode 100644 index 00000000..cacc4fd3 --- /dev/null +++ b/src/lib/consent/__tests__/storageClasses.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useConsentStore } from '../store'; +import { createDefaultConsentState } from '../types'; +import { + clearStorageClassRegistry, + enforceStorageClasses, + getClassifiedItem, + getStorageClass, + isStorageClassAllowed, + listRegisteredStorageKeys, + purgeDisallowedStorage, + registerStorageKey, + removeClassifiedItem, + setClassifiedItem, + unregisterStorageKey, +} from '../storageClasses'; + +function clearAllCookies() { + document.cookie.split(';').forEach((entry) => { + const name = entry.split('=')[0]?.trim(); + if (name) document.cookie = `${name}=; path=/; max-age=0`; + }); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + clearAllCookies(); + useConsentStore.setState(createDefaultConsentState()); + clearStorageClassRegistry(); +}); + +describe('storage key registry', () => { + it('registers and retrieves a descriptor', () => { + registerStorageKey({ key: 'foo', category: 'analytics', area: 'localStorage' }); + expect(getStorageClass('foo')).toEqual({ key: 'foo', category: 'analytics', area: 'localStorage' }); + }); + + it('returns undefined for an unregistered key', () => { + expect(getStorageClass('missing')).toBeUndefined(); + }); + + it('unregisterStorageKey removes a descriptor', () => { + registerStorageKey({ key: 'foo', category: 'analytics', area: 'localStorage' }); + unregisterStorageKey('foo'); + expect(getStorageClass('foo')).toBeUndefined(); + }); + + it('listRegisteredStorageKeys lists everything registered', () => { + registerStorageKey({ key: 'a', category: 'analytics', area: 'localStorage' }); + registerStorageKey({ key: 'b', category: 'marketing', area: 'cookie' }); + expect(listRegisteredStorageKeys()).toHaveLength(2); + }); + + it('clearStorageClassRegistry forgets everything', () => { + registerStorageKey({ key: 'foo', category: 'analytics', area: 'localStorage' }); + clearStorageClassRegistry(); + expect(listRegisteredStorageKeys()).toHaveLength(0); + }); +}); + +describe('isStorageClassAllowed', () => { + it('always allows the necessary category', () => { + expect(isStorageClassAllowed('necessary')).toBe(true); + }); + + it('disallows optional categories before a decision is made', () => { + expect(isStorageClassAllowed('analytics')).toBe(false); + expect(isStorageClassAllowed('functional')).toBe(false); + expect(isStorageClassAllowed('marketing')).toBe(false); + }); + + it('allows a category once accepted', () => { + useConsentStore.getState().acceptAll(); + expect(isStorageClassAllowed('analytics')).toBe(true); + expect(isStorageClassAllowed('marketing')).toBe(true); + }); + + it('disallows a category after it is explicitly rejected', () => { + useConsentStore.getState().acceptAll(); + useConsentStore.getState().rejectAll(); + expect(isStorageClassAllowed('analytics')).toBe(false); + }); +}); + +describe('setClassifiedItem / getClassifiedItem', () => { + it('blocks localStorage writes for a non-consented category', () => { + const ok = setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123'); + expect(ok).toBe(false); + expect(localStorage.getItem('ga_client_id')).toBeNull(); + }); + + it('always allows writes for the necessary category', () => { + const ok = setClassifiedItem({ key: 'session_id', category: 'necessary', area: 'localStorage' }, 'abc'); + expect(ok).toBe(true); + expect(localStorage.getItem('session_id')).toBe('abc'); + }); + + it('allows writes once the category is consented to', () => { + useConsentStore.getState().savePreferences({ analytics: true }); + const ok = setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123'); + expect(ok).toBe(true); + expect(localStorage.getItem('ga_client_id')).toBe('123'); + }); + + it('round-trips sessionStorage entries', () => { + useConsentStore.getState().savePreferences({ functional: true }); + setClassifiedItem({ key: 'ui_layout', category: 'functional', area: 'sessionStorage' }, 'grid'); + expect(getClassifiedItem('ui_layout')).toBe('grid'); + expect(sessionStorage.getItem('ui_layout')).toBe('grid'); + }); + + it('round-trips cookie entries', () => { + useConsentStore.getState().savePreferences({ marketing: true }); + setClassifiedItem({ key: 'ad_id', category: 'marketing', area: 'cookie' }, 'xyz'); + expect(getClassifiedItem('ad_id')).toBe('xyz'); + expect(document.cookie).toContain('ad_id=xyz'); + }); + + it('registers the descriptor even when the write is blocked', () => { + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123'); + expect(getStorageClass('ga_client_id')).toEqual({ + key: 'ga_client_id', + category: 'analytics', + area: 'localStorage', + }); + }); + + it('returns null for an unregistered key', () => { + expect(getClassifiedItem('unknown')).toBeNull(); + }); + + it('returns null once consent is withdrawn, even if the raw value is still present', () => { + useConsentStore.getState().savePreferences({ analytics: true }); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123'); + + useConsentStore.getState().savePreferences({ analytics: false }); + + expect(getClassifiedItem('ga_client_id')).toBeNull(); + // the raw value is untouched until a purge runs + expect(localStorage.getItem('ga_client_id')).toBe('123'); + }); +}); + +describe('removeClassifiedItem', () => { + it('removes a registered entry from its underlying storage', () => { + setClassifiedItem({ key: 'session_id', category: 'necessary', area: 'localStorage' }, 'abc'); + removeClassifiedItem('session_id'); + expect(localStorage.getItem('session_id')).toBeNull(); + }); + + it('is a no-op for an unregistered key', () => { + expect(() => removeClassifiedItem('unknown')).not.toThrow(); + }); +}); + +describe('purgeDisallowedStorage', () => { + it('removes localStorage, sessionStorage, and cookie entries for revoked categories, keeping necessary/allowed ones', () => { + useConsentStore.getState().acceptAll(); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1'); + setClassifiedItem({ key: 'ui_layout', category: 'functional', area: 'sessionStorage' }, 'grid'); + setClassifiedItem({ key: 'ad_id', category: 'marketing', area: 'cookie' }, 'x'); + setClassifiedItem({ key: 'session_id', category: 'necessary', area: 'localStorage' }, 'abc'); + + useConsentStore.getState().rejectAll(); + const purged = purgeDisallowedStorage(); + + expect([...purged].sort()).toEqual(['ad_id', 'ga_client_id', 'ui_layout']); + expect(localStorage.getItem('ga_client_id')).toBeNull(); + expect(sessionStorage.getItem('ui_layout')).toBeNull(); + expect(document.cookie).not.toContain('ad_id='); + expect(localStorage.getItem('session_id')).toBe('abc'); + }); + + it('is a no-op when nothing is registered', () => { + expect(purgeDisallowedStorage()).toEqual([]); + }); + + it('is a no-op when every registered category is still allowed', () => { + useConsentStore.getState().acceptAll(); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1'); + expect(purgeDisallowedStorage()).toEqual([]); + expect(localStorage.getItem('ga_client_id')).toBe('1'); + }); +}); + +describe('enforceStorageClasses', () => { + it('purges already-disallowed entries immediately on start', () => { + useConsentStore.getState().acceptAll(); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1'); + useConsentStore.getState().rejectAll(); + expect(localStorage.getItem('ga_client_id')).toBe('1'); // stale until enforcement starts + + const unsubscribe = enforceStorageClasses(); + + expect(localStorage.getItem('ga_client_id')).toBeNull(); + unsubscribe(); + }); + + it('re-purges automatically whenever consent changes', () => { + const unsubscribe = enforceStorageClasses(); + + useConsentStore.getState().savePreferences({ analytics: true }); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '2'); + expect(localStorage.getItem('ga_client_id')).toBe('2'); + + useConsentStore.getState().rejectAll(); + expect(localStorage.getItem('ga_client_id')).toBeNull(); + + unsubscribe(); + }); + + it('stops purging after unsubscribe is called', () => { + const unsubscribe = enforceStorageClasses(); + unsubscribe(); + + useConsentStore.getState().savePreferences({ analytics: true }); + setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1'); + useConsentStore.getState().rejectAll(); + + // no active subscription, so the stale value survives until something else purges it + expect(localStorage.getItem('ga_client_id')).toBe('1'); + }); +}); diff --git a/src/lib/consent/storageClasses.ts b/src/lib/consent/storageClasses.ts new file mode 100644 index 00000000..67a8f85e --- /dev/null +++ b/src/lib/consent/storageClasses.ts @@ -0,0 +1,183 @@ +'use client'; + +/** + * @module consent/storageClasses + * + * Storage Classes for GDPR compliance. + * + * Every piece of client-side storage (localStorage, sessionStorage, cookies) that + * isn't strictly necessary for the app to function must be tied to a consent + * category (see `CookieCategory` in `./types`). This module lets call sites + * declare which category a given storage key belongs to, gates reads/writes + * against the user's current consent, and purges storage that becomes + * disallowed when consent is withdrawn (GDPR Art. 7(3): withdrawing consent + * must be as effective as giving it). + */ +import { useEffect } from 'react'; +import { useConsentStore } from './store'; +import type { CookieCategory } from './types'; + +export type StorageArea = 'localStorage' | 'sessionStorage' | 'cookie'; + +export interface StorageClassDescriptor { + /** Storage key (or cookie name) this descriptor governs. */ + key: string; + /** GDPR consent category this storage entry belongs to. */ + category: CookieCategory; + /** Underlying browser storage mechanism. */ + area: StorageArea; +} + +const registry = new Map(); + +/** Declares a storage key's GDPR category so it can be gated and purged. */ +export function registerStorageKey(descriptor: StorageClassDescriptor): void { + registry.set(descriptor.key, descriptor); +} + +export function unregisterStorageKey(key: string): void { + registry.delete(key); +} + +export function getStorageClass(key: string): StorageClassDescriptor | undefined { + return registry.get(key); +} + +export function listRegisteredStorageKeys(): StorageClassDescriptor[] { + return Array.from(registry.values()); +} + +/** Forgets every registered descriptor. Mainly useful for tests and logout flows. */ +export function clearStorageClassRegistry(): void { + registry.clear(); +} + +/** Whether the given category is currently consented to (necessary is always allowed). */ +export function isStorageClassAllowed(category: CookieCategory): boolean { + if (category === 'necessary') return true; + return useConsentStore.getState().preferences[category]; +} + +function readCookie(name: string): string | null { + if (typeof document === 'undefined' || !document.cookie) return null; + for (const entry of document.cookie.split('; ')) { + const separatorIndex = entry.indexOf('='); + if (separatorIndex === -1) continue; + if (entry.slice(0, separatorIndex) === name) { + return decodeURIComponent(entry.slice(separatorIndex + 1)); + } + } + return null; +} + +function writeCookie(name: string, value: string): void { + if (typeof document === 'undefined') return; + const maxAge = 365 * 24 * 60 * 60; + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`; +} + +function deleteCookie(name: string): void { + if (typeof document === 'undefined') return; + document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`; +} + +function getBrowserStorage(area: 'localStorage' | 'sessionStorage'): Storage | null { + if (typeof window === 'undefined') return null; + try { + return area === 'localStorage' ? window.localStorage : window.sessionStorage; + } catch { + // Storage unavailable (e.g. private browsing with strict settings) + return null; + } +} + +function removeFromStorage(descriptor: StorageClassDescriptor): void { + if (descriptor.area === 'cookie') { + deleteCookie(descriptor.key); + return; + } + try { + getBrowserStorage(descriptor.area)?.removeItem(descriptor.key); + } catch { + // ignore + } +} + +/** + * Registers the descriptor and writes `value` to its storage area, but only + * if the descriptor's category is currently consented to. Returns whether + * the write actually happened. + */ +export function setClassifiedItem(descriptor: StorageClassDescriptor, value: string): boolean { + registerStorageKey(descriptor); + if (!isStorageClassAllowed(descriptor.category)) return false; + + if (descriptor.area === 'cookie') { + writeCookie(descriptor.key, value); + return true; + } + const storage = getBrowserStorage(descriptor.area); + if (!storage) return false; + try { + storage.setItem(descriptor.key, value); + return true; + } catch { + return false; + } +} + +/** + * Reads a previously registered key. Returns null for unregistered keys and + * for keys whose category is not currently consented to, even if a stale + * value is still physically present in storage. + */ +export function getClassifiedItem(key: string): string | null { + const descriptor = registry.get(key); + if (!descriptor || !isStorageClassAllowed(descriptor.category)) return null; + + if (descriptor.area === 'cookie') return readCookie(key); + try { + return getBrowserStorage(descriptor.area)?.getItem(key) ?? null; + } catch { + return null; + } +} + +/** Removes a registered key's value from its underlying storage area. */ +export function removeClassifiedItem(key: string): void { + const descriptor = registry.get(key); + if (!descriptor) return; + removeFromStorage(descriptor); +} + +/** + * Removes every registered storage entry whose category is no longer + * consented to. Returns the keys that were purged. + */ +export function purgeDisallowedStorage(): string[] { + const purged: string[] = []; + for (const descriptor of registry.values()) { + if (!isStorageClassAllowed(descriptor.category)) { + removeFromStorage(descriptor); + purged.push(descriptor.key); + } + } + return purged; +} + +/** + * Starts enforcing storage classes: purges any already-disallowed entries + * immediately, then re-purges on every consent change. Returns an + * unsubscribe function. + */ +export function enforceStorageClasses(): () => void { + purgeDisallowedStorage(); + return useConsentStore.subscribe(() => { + purgeDisallowedStorage(); + }); +} + +/** React hook that runs {@link enforceStorageClasses} for the lifetime of the component. */ +export function useStorageClassEnforcement(): void { + useEffect(() => enforceStorageClasses(), []); +}