Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/lib/consent/__tests__/store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions src/lib/consent/constants.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
13 changes: 11 additions & 2 deletions src/lib/consent/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +68,7 @@ function recordDecision(
decided: true,
preferences,
decidedAt,
acceptedPolicyVersion: CONSENT_POLICY_VERSION,
});
if (!parsed.success) return;
set(parsed.data);
Expand Down Expand Up @@ -90,8 +96,10 @@ export const useConsentStore = create<ConsentSlice>()(
},

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;
},
}),
Expand All @@ -103,6 +111,7 @@ export const useConsentStore = create<ConsentSlice>()(
decided: state.decided,
preferences: state.preferences,
decidedAt: state.decidedAt,
acceptedPolicyVersion: state.acceptedPolicyVersion,
}),
},
),
Expand Down
5 changes: 4 additions & 1 deletion src/lib/consent/types.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<typeof consentStateSchema>;

Expand All @@ -41,6 +43,7 @@ export function createDefaultConsentState(): ConsentState {
marketing: false,
},
decidedAt: null,
acceptedPolicyVersion: CONSENT_POLICY_VERSION,
};
}

Expand Down
Loading