diff --git a/coverage/summary.json b/coverage/summary.json index 26e27a2..f49ce04 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-19T12:48:28.197Z", - "commit": "7dd46b6", + "generatedAt": "2026-05-19T12:59:06.953Z", + "commit": "c346f85", "repo": { - "statements": 96.63, - "branches": 91.77, - "functions": 96.74, - "lines": 96.63, + "statements": 96.65, + "branches": 91.95, + "functions": 96.77, + "lines": 96.65, "counts": { - "lf": 4476, - "lh": 4325, - "brf": 875, - "brh": 803, - "fnf": 276, - "fnh": 267 + "lf": 4511, + "lh": 4360, + "brf": 894, + "brh": 822, + "fnf": 279, + "fnh": 270 } }, "byPackage": { @@ -59,17 +59,17 @@ } }, "@repo/core-consent": { - "statements": 99.49, - "branches": 91.67, - "functions": 95.65, - "lines": 99.49, + "statements": 99.57, + "branches": 93.67, + "functions": 96.15, + "lines": 99.57, "counts": { - "lf": 195, - "lh": 194, - "brf": 60, - "brh": 55, - "fnf": 23, - "fnh": 22 + "lf": 230, + "lh": 229, + "brf": 79, + "brh": 74, + "fnf": 26, + "fnh": 25 } }, "@repo/core-shared": { diff --git a/packages/core-consent/src/index.ts b/packages/core-consent/src/index.ts index 5088389..94debaa 100644 --- a/packages/core-consent/src/index.ts +++ b/packages/core-consent/src/index.ts @@ -16,3 +16,8 @@ export { type ConsentFactory, } from "./di/bind-production"; export { bindDevSeedConsent } from "./di/bind-dev-seed"; +export { + CONSENT_COOKIE_NAME, + extractAnonymousConsent, + migrateAnonymousConsent, +} from "./migration"; diff --git a/packages/core-consent/src/migration.test.ts b/packages/core-consent/src/migration.test.ts new file mode 100644 index 0000000..2236061 --- /dev/null +++ b/packages/core-consent/src/migration.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { RecordingConsent } from "@repo/core-testing/instrumentation"; +import { + extractAnonymousConsent, + migrateAnonymousConsent, + CONSENT_COOKIE_NAME, +} from "@/migration"; + +describe("extractAnonymousConsent", () => { + it("returns null when no consent cookie is present", () => { + expect(extractAnonymousConsent("session=abc; other=xyz")).toBeNull(); + }); + + it("returns null for an empty header", () => { + expect(extractAnonymousConsent("")).toBeNull(); + }); + + it("extracts granted categories from the consent cookie", () => { + const result = extractAnonymousConsent( + `${CONSENT_COOKIE_NAME}=necessary,analytics; session=abc`, + ); + expect(result).toEqual(["necessary", "analytics"]); + }); + + it("returns null when the consent cookie value is empty", () => { + expect(extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=`)).toBeNull(); + }); + + it("trims whitespace around category names", () => { + const result = extractAnonymousConsent( + `${CONSENT_COOKIE_NAME}= necessary , analytics `, + ); + expect(result).toEqual(["necessary", "analytics"]); + }); + + it("returns a single category when only one is present", () => { + const result = extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=marketing`); + expect(result).toEqual(["marketing"]); + }); + + it("returns null when cookie value contains only commas/whitespace", () => { + expect(extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=,, ,`)).toBeNull(); + }); +}); + +describe("migrateAnonymousConsent", () => { + it("calls IConsent.grant with method signup-migration for each category", async () => { + const consent = new RecordingConsent(); + await migrateAnonymousConsent({ + consent, + cookieState: ["necessary", "analytics"], + bannerVersion: "v2", + policyVersion: "2026-01", + }); + expect(consent.grants).toHaveLength(2); + expect(consent.grants[0]).toEqual({ + category: "necessary", + meta: { + method: "signup-migration", + bannerVersion: "v2", + policyVersion: "2026-01", + }, + }); + expect(consent.grants[1]).toEqual({ + category: "analytics", + meta: { + method: "signup-migration", + bannerVersion: "v2", + policyVersion: "2026-01", + }, + }); + }); + + it("is a no-op when cookieState is null (absent cookie)", async () => { + const consent = new RecordingConsent(); + await migrateAnonymousConsent({ consent, cookieState: null }); + expect(consent.grants).toHaveLength(0); + }); + + it("omits bannerVersion and policyVersion from meta when not provided", async () => { + const consent = new RecordingConsent(); + await migrateAnonymousConsent({ + consent, + cookieState: ["marketing"], + }); + expect(consent.grants).toHaveLength(1); + expect(consent.grants[0]!.meta).toEqual({ method: "signup-migration" }); + }); + + it("happy path: cookie header present → grant called with signup-migration on all categories", async () => { + const consent = new RecordingConsent(); + const cookieState = extractAnonymousConsent( + `${CONSENT_COOKIE_NAME}=necessary,marketing`, + ); + await migrateAnonymousConsent({ + consent, + cookieState, + bannerVersion: "v1", + policyVersion: "2025-12", + }); + expect(consent.grants).toHaveLength(2); + expect(consent.grants[0]!.meta?.method).toBe("signup-migration"); + expect(consent.grants[1]!.meta?.method).toBe("signup-migration"); + expect(consent.isGranted("necessary")).toBe(true); + expect(consent.isGranted("marketing")).toBe(true); + }); +}); diff --git a/packages/core-consent/src/migration.ts b/packages/core-consent/src/migration.ts new file mode 100644 index 0000000..b3f13d5 --- /dev/null +++ b/packages/core-consent/src/migration.ts @@ -0,0 +1,60 @@ +import type { ConsentCategory, ConsentGrantMeta } from "./consent-types"; +import type { IConsent } from "./consent.interface"; + +/** Cookie name written by the anonymous consent banner. */ +export const CONSENT_COOKIE_NAME = "cc_consent"; + +/** + * Parses a raw Cookie header string and returns the consent categories the + * anonymous visitor granted via the banner cookie. Returns null when the + * consent cookie is absent or empty. + * + * Expected cookie value format: comma-separated category names, + * e.g. "necessary,analytics,marketing". + */ +export function extractAnonymousConsent( + cookieHeader: string, +): ConsentCategory[] | null { + const cookies = parseCookieHeader(cookieHeader); + const raw = cookies.get(CONSENT_COOKIE_NAME); + if (!raw) return null; + const categories = raw + .split(",") + .map((c) => c.trim()) + .filter(Boolean) as ConsentCategory[]; + return categories.length > 0 ? categories : null; +} + +/** + * Migrates anonymous consent categories into an authenticated user's consent + * record. Calls IConsent.grant for each category with method "signup-migration" + * so the migration is traceable in the audit log. No-op when cookieState is + * null (visitor had no consent cookie). + */ +export async function migrateAnonymousConsent(opts: { + consent: IConsent; + cookieState: ConsentCategory[] | null; + bannerVersion?: string; + policyVersion?: string; +}): Promise { + const { consent, cookieState, bannerVersion, policyVersion } = opts; + if (!cookieState) return; + const meta: ConsentGrantMeta = { method: "signup-migration" }; + if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion; + if (policyVersion !== undefined) meta.policyVersion = policyVersion; + for (const category of cookieState) { + await consent.grant(category, meta); + } +} + +function parseCookieHeader(cookieHeader: string): Map { + const map = new Map(); + for (const part of cookieHeader.split(";")) { + const eqIdx = part.indexOf("="); + if (eqIdx === -1) continue; + const name = part.slice(0, eqIdx).trim(); + const value = part.slice(eqIdx + 1).trim(); + if (name) map.set(name, value); + } + return map; +}