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>
239 lines
8.3 KiB
TypeScript
239 lines
8.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { ZodError } from "zod";
|
|
import {
|
|
RecordingEventBus,
|
|
RecordingConsent,
|
|
} from "@repo/core-testing/instrumentation";
|
|
import {
|
|
signUpUseCase,
|
|
signUpOutputSchema,
|
|
} from "@/application/use-cases/sign-up.use-case";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
|
import { AuthenticationError } from "@/entities/errors/auth";
|
|
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
|
import { userFactory } from "@/__factories__/user.factory";
|
|
|
|
describe("signUpUseCase", () => {
|
|
it("creates a new user and returns session + cookie", 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: "carol",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
});
|
|
|
|
expect(result.session.userId).toBeTruthy();
|
|
expect(result.cookie.name).toBe("session");
|
|
});
|
|
|
|
it("throws AuthenticationError when username taken", async () => {
|
|
const users = new MockUsersRepository([]);
|
|
const auth = new MockAuthenticationService(users);
|
|
const bus = new RecordingEventBus();
|
|
await users.createUser(userFactory.build({ username: "alice" }));
|
|
|
|
const useCase = signUpUseCase(users, auth, bus, undefined);
|
|
await expect(
|
|
useCase({
|
|
username: "alice",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
}),
|
|
).rejects.toBeInstanceOf(AuthenticationError);
|
|
});
|
|
|
|
it("publishes auth.user.signed-up after creating the user", async () => {
|
|
const users = new MockUsersRepository([]);
|
|
const auth = new MockAuthenticationService(users);
|
|
const bus = new RecordingEventBus();
|
|
const useCase = signUpUseCase(users, auth, bus, undefined);
|
|
|
|
await useCase({
|
|
username: "dave",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
});
|
|
|
|
expect(bus.published).toHaveLength(1);
|
|
const published = bus.published[0]!;
|
|
expect(published.name).toBe("auth.user.signed-up");
|
|
expect(published.payload).toEqual(
|
|
expect.objectContaining({
|
|
userId: expect.any(String),
|
|
email: expect.stringMatching(/^dave@/),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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, undefined);
|
|
|
|
const result = await useCase({
|
|
username: "frank",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
});
|
|
|
|
expect(result.session.userId).toBeTruthy();
|
|
expect(result.cookie.name).toBe("session");
|
|
});
|
|
|
|
it("does NOT publish when sign-up fails (username taken)", async () => {
|
|
const users = new MockUsersRepository([]);
|
|
const auth = new MockAuthenticationService(users);
|
|
const bus = new RecordingEventBus();
|
|
await users.createUser(userFactory.build({ username: "eve" }));
|
|
|
|
const useCase = signUpUseCase(users, auth, bus, undefined);
|
|
await expect(
|
|
useCase({
|
|
username: "eve",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
}),
|
|
).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", () => {
|
|
it("throws when authenticationService returns a malformed session", async () => {
|
|
const users = new MockUsersRepository([]);
|
|
const auth = {
|
|
hashPassword: async () => "hashed_x",
|
|
generateUserId: () => "uid1",
|
|
verifyPassword: async () => true,
|
|
// session missing required fields → should fail signUpOutputSchema.parse
|
|
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
|
} as unknown as IAuthenticationService;
|
|
|
|
const bus = new RecordingEventBus();
|
|
const useCase = signUpUseCase(users, auth, bus, undefined);
|
|
await expect(
|
|
useCase({
|
|
username: "carol",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
}),
|
|
).rejects.toBeInstanceOf(ZodError);
|
|
});
|
|
|
|
it("exports an output schema that mirrors the success shape", () => {
|
|
expect(signUpOutputSchema).toBeDefined();
|
|
const parsed = signUpOutputSchema.safeParse({
|
|
session: { id: "s1", userId: "u1", expiresAt: new Date() },
|
|
cookie: { name: "session", value: "s1", attributes: {} },
|
|
});
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
});
|