feat(auth): migrate anonymous consent on signUp when cc_consent cookie present

Adds ConsentFactoryProtocol / ConsentGrantMeta / ConsentProtocol to
core-shared/di/bind-protocols so feature binders can wire per-user
consent without a hard dep on the optional @repo/core-consent package.
BindContext gains an optional consentFactory? field following the same
pattern as bus?, auditLog?, etc.

signUpUseCase gains a 4th optional dep (consentFactory). When present
and the input includes a cookieHeader containing cc_consent=<categories>,
the use case calls consent.grant for each category with
method:"signup-migration" and returns a clearCookie payload (Max-Age:0)
so the anonymous cookie is cleared on the HTTP response.

Tests use RecordingConsent from @repo/core-testing to assert migration
call shape and cookie-clear; no-cookie and no-factory branches are also
covered. All coverage bands hold at 100% for use-cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 21:52:08 +00:00
parent 5151454783
commit 6b66064386
11 changed files with 272 additions and 54 deletions

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
import {
RecordingEventBus,
RecordingConsent,
} from "@repo/core-testing/instrumentation";
import {
signUpUseCase,
signUpOutputSchema,
@@ -16,7 +19,7 @@ describe("signUpUseCase", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
const result = await useCase({
username: "carol",
@@ -34,7 +37,7 @@ describe("signUpUseCase", () => {
const bus = new RecordingEventBus();
await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "alice",
@@ -48,7 +51,7 @@ describe("signUpUseCase", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await useCase({
username: "dave",
@@ -70,7 +73,7 @@ describe("signUpUseCase", () => {
it("works without an event bus (welcome email skipped silently)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth, undefined);
const useCase = signUpUseCase(users, auth, undefined, undefined);
const result = await useCase({
username: "frank",
@@ -88,7 +91,7 @@ describe("signUpUseCase", () => {
const bus = new RecordingEventBus();
await users.createUser(userFactory.build({ username: "eve" }));
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "eve",
@@ -98,6 +101,108 @@ describe("signUpUseCase", () => {
).rejects.toBeInstanceOf(AuthenticationError);
expect(bus.published).toHaveLength(0);
});
it("migrates anonymous consent when cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "grace",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=necessary,analytics; session=xyz",
});
expect(consent.grants).toHaveLength(2);
expect(consent.grants[0]).toEqual({
category: "necessary",
meta: { method: "signup-migration" },
});
expect(consent.grants[1]).toEqual({
category: "analytics",
meta: { method: "signup-migration" },
});
expect(result.clearCookie).toBeDefined();
expect(result.clearCookie?.name).toBe("cc_consent");
expect(result.clearCookie?.attributes.maxAge).toBe(0);
});
it("does not migrate consent when no cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "henry",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "session=xyz",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when consentFactory is absent", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus, undefined);
const result = await useCase({
username: "iris",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=analytics",
});
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when cc_consent cookie has no value", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "jake",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("parses cookie header with malformed parts (no = sign)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "kate",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "malformedcookie; cc_consent=necessary",
});
expect(consent.grants).toHaveLength(1);
expect(result.clearCookie).toBeDefined();
});
});
describe("signUpUseCase output validation", () => {
@@ -112,7 +217,7 @@ describe("signUpUseCase output validation", () => {
} as unknown as IAuthenticationService;
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "carol",