From 025e8d9ec5dee44829a1c47bad08889e1af58a38 Mon Sep 17 00:00:00 2001 From: bug_lord <287525836+OpensrcLord@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:22:51 +0100 Subject: [PATCH] feat(consent): version consent records and re-prompt on policy change Track the policy version accepted by each consent decision and invalidate consent whenever the policy is bumped, re-prompting returning users. Closes #1185 --- src/lib/consent/__tests__/store.test.ts | 89 +++++++++++++++++++++++++ src/lib/consent/constants.ts | 7 ++ src/lib/consent/store.ts | 13 +++- src/lib/consent/types.ts | 5 +- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/lib/consent/__tests__/store.test.ts diff --git a/src/lib/consent/__tests__/store.test.ts b/src/lib/consent/__tests__/store.test.ts new file mode 100644 index 00000000..b1e9a4f7 --- /dev/null +++ b/src/lib/consent/__tests__/store.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + CONSENT_POLICY_VERSION, + CONSENT_STORAGE_KEY, + CONSENT_TTL_MS, +} from '../constants'; +import { useConsentStore } from '../store'; +import { + createAcceptAllPreferences, + createDefaultConsentState, +} from '../types'; + +function clearAllCookies() { + document.cookie.split(';').forEach((entry) => { + const name = entry.split('=')[0]?.trim(); + if (name) document.cookie = `${name}=; path=/; max-age=0`; + }); +} + +function acceptUnderPolicy() { + useConsentStore.getState().acceptAll(); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + clearAllCookies(); + useConsentStore.setState(createDefaultConsentState()); +}); + +describe('consent versioning', () => { + it('defaults the accepted policy version to the current one', () => { + expect(useConsentStore.getState().acceptedPolicyVersion).toBe( + CONSENT_POLICY_VERSION, + ); + }); + + it('records the accepted policy version on accept all', () => { + acceptUnderPolicy(); + const state = useConsentStore.getState(); + expect(state.decided).toBe(true); + expect(state.acceptedPolicyVersion).toBe(CONSENT_POLICY_VERSION); + expect(state.decidedAt).not.toBeNull(); + }); + + it('records the accepted policy version on custom preferences', () => { + useConsentStore.getState().savePreferences({ analytics: true }); + expect(useConsentStore.getState().acceptedPolicyVersion).toBe( + CONSENT_POLICY_VERSION, + ); + }); +}); + +describe('isConsentValid / policy change', () => { + it('returns true for a fresh decision under the current policy', () => { + acceptUnderPolicy(); + expect(useConsentStore.getState().isConsentValid()).toBe(true); + }); + + it('returns false when consent was decided under an older policy', () => { + acceptUnderPolicy(); + useConsentStore.setState({ + acceptedPolicyVersion: CONSENT_POLICY_VERSION - 1, + }); + expect(useConsentStore.getState().isConsentValid()).toBe(false); + }); + + it('returns false when the decision is older than the TTL', () => { + useConsentStore.setState({ + decided: true, + decidedAt: Date.now() - (CONSENT_TTL_MS + 1), + acceptedPolicyVersion: CONSENT_POLICY_VERSION, + preferences: createAcceptAllPreferences(), + }); + expect(useConsentStore.getState().isConsentValid()).toBe(false); + }); + + it('returns false before any decision is made', () => { + expect(useConsentStore.getState().isConsentValid()).toBe(false); + }); + + it('persists the accepted policy version alongside the decision', () => { + acceptUnderPolicy(); + const raw = localStorage.getItem(CONSENT_STORAGE_KEY); + expect(raw).not.toBeNull(); + const persisted = JSON.parse(raw as string); + expect(persisted.state.acceptedPolicyVersion).toBe(CONSENT_POLICY_VERSION); + }); +}); \ No newline at end of file diff --git a/src/lib/consent/constants.ts b/src/lib/consent/constants.ts index 38705ce4..cbc6c6ab 100644 --- a/src/lib/consent/constants.ts +++ b/src/lib/consent/constants.ts @@ -1,5 +1,12 @@ export const CONSENT_SCHEMA_VERSION = 1 as const; +/** + * Version of the consent policy shown to users. Bump this when the wording or + * the set of collected categories changes so that users who accepted an older + * policy are re-prompted on their next visit. + */ +export const CONSENT_POLICY_VERSION = 1 as const; + /** localStorage key for persisted consent state */ export const CONSENT_STORAGE_KEY = 'teachlink-cookie-consent-v1'; diff --git a/src/lib/consent/store.ts b/src/lib/consent/store.ts index 7e1696fe..42cd1eec 100644 --- a/src/lib/consent/store.ts +++ b/src/lib/consent/store.ts @@ -8,7 +8,12 @@ */ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -import { CONSENT_COOKIE_NAME, CONSENT_STORAGE_KEY, CONSENT_TTL_MS } from './constants'; +import { + CONSENT_COOKIE_NAME, + CONSENT_POLICY_VERSION, + CONSENT_STORAGE_KEY, + CONSENT_TTL_MS, +} from './constants'; import { type ConsentPreferences, type ConsentState, @@ -63,6 +68,7 @@ function recordDecision( decided: true, preferences, decidedAt, + acceptedPolicyVersion: CONSENT_POLICY_VERSION, }); if (!parsed.success) return; set(parsed.data); @@ -90,8 +96,10 @@ export const useConsentStore = create()( }, isConsentValid: () => { - const { decided, decidedAt } = get(); + const { decided, decidedAt, acceptedPolicyVersion } = get(); if (!decided || decidedAt === null) return false; + // Re-prompt when the policy changed after the user's last decision. + if (acceptedPolicyVersion !== CONSENT_POLICY_VERSION) return false; return Date.now() - decidedAt < CONSENT_TTL_MS; }, }), @@ -103,6 +111,7 @@ export const useConsentStore = create()( decided: state.decided, preferences: state.preferences, decidedAt: state.decidedAt, + acceptedPolicyVersion: state.acceptedPolicyVersion, }), }, ), diff --git a/src/lib/consent/types.ts b/src/lib/consent/types.ts index 414e371b..36ccff0d 100644 --- a/src/lib/consent/types.ts +++ b/src/lib/consent/types.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { CONSENT_SCHEMA_VERSION } from './constants'; +import { CONSENT_POLICY_VERSION, CONSENT_SCHEMA_VERSION } from './constants'; /** * Granular cookie categories following IAB TCF conventions. @@ -26,6 +26,8 @@ export const consentStateSchema = z.object({ preferences: consentPreferencesSchema, /** Unix ms timestamp of when consent was last recorded. */ decidedAt: z.number().nullable(), + /** Consent policy version in effect when the decision was recorded. */ + acceptedPolicyVersion: z.number().int().min(1), }); export type ConsentState = z.infer; @@ -41,6 +43,7 @@ export function createDefaultConsentState(): ConsentState { marketing: false, }, decidedAt: null, + acceptedPolicyVersion: CONSENT_POLICY_VERSION, }; }