feat(core-consent): add extractAnonymousConsent and migrateAnonymousConsent helpers
Migration helpers let the auth signUp flow transfer anonymous cookie consent to an authenticated user's record. extractAnonymousConsent parses the raw Cookie header; migrateAnonymousConsent calls IConsent.grant with method: "signup-migration" for each granted category, making the migration traceable in the audit log. No-op when the consent cookie is absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
107
packages/core-consent/src/migration.test.ts
Normal file
107
packages/core-consent/src/migration.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
60
packages/core-consent/src/migration.ts
Normal file
60
packages/core-consent/src/migration.ts
Normal file
@@ -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<void> {
|
||||
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<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user