diff --git a/packages/core-consent/package.json b/packages/core-consent/package.json index e3a697c..c2479bd 100644 --- a/packages/core-consent/package.json +++ b/packages/core-consent/package.json @@ -36,8 +36,10 @@ "@repo/core-testing": "workspace:*", "@repo/core-typescript": "workspace:*", "@testing-library/react": "^16.0.0", + "@trpc/client": "^11.18.0", "@types/react": "^19.0.0", "@vitest/coverage-v8": "^3.2.7", + "superjson": "^2.2.1", "jsdom": "^25.0.0", "payload": "^3.14.0", "react": "^19.0.0", diff --git a/packages/core-consent/src/consent.router.test.ts b/packages/core-consent/src/consent.router.test.ts index 080c0fe..1c81cf3 100644 --- a/packages/core-consent/src/consent.router.test.ts +++ b/packages/core-consent/src/consent.router.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, beforeEach } from "vitest"; import { TRPCError } from "@trpc/server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { createTRPCClient, httpLink } from "@trpc/client"; +import superjson from "superjson"; import { RecordingConsent } from "@repo/core-testing/instrumentation"; +import { router } from "@repo/core-shared/trpc/init"; import { consentRouter } from "@/consent.router"; import type { ConsentRouterContext } from "@/consent.router"; import type { IConsent } from "@/consent.interface"; +import { InMemoryConsent } from "@/in-memory-consent"; function makeContext( consent: RecordingConsent, @@ -155,6 +160,77 @@ describe("consentRouter — auth checks", () => { }); }); +describe("consentRouter — context guard", () => { + it("throws INTERNAL_SERVER_ERROR when consentFactory is missing from ctx", async () => { + const caller = consentRouter.createCaller({ + userId: "user-1", + } as unknown as ConsentRouterContext); + await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject( + { + code: "INTERNAL_SERVER_ERROR", + message: expect.stringContaining("consentFactory missing"), + }, + ); + }); +}); + +describe("consentRouter — superjson wire round-trip (A10)", () => { + // The consent router MUST be built from the shared `t` (which is created + // with the superjson transformer). This test drives a real tRPC HTTP + // round-trip — client link + fetch adapter — so a transformer mismatch + // between the mounted router and the app client fails loudly here. + function makeClient(ctx: ConsentRouterContext) { + const appLikeRouter = router({ consent: consentRouter }); + return createTRPCClient({ + links: [ + httpLink({ + url: "http://localhost/api/trpc", + transformer: superjson, + fetch: (input, init) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req: new Request(input, init as RequestInit), + router: appLikeRouter, + createContext: () => ctx, + }), + }), + ], + }); + } + + it("round-trips grant + getCategories, reviving Date fields", async () => { + const consent = new InMemoryConsent(); + const client = makeClient({ + userId: "user-1", + consentFactory: async () => consent, + }); + + const grantRes = await client.consent.grant.mutate({ + category: "analytics", + }); + expect(grantRes).toEqual({ success: true }); + + const { categories } = await client.consent.getCategories.query({}); + expect(categories).toHaveLength(1); + expect(categories[0]!.category).toBe("analytics"); + // superjson revives Dates across the wire; plain JSON would yield a string. + expect(categories[0]!.grantedAt).toBeInstanceOf(Date); + }); + + it("round-trips isGranted through the wire", async () => { + const consent = new InMemoryConsent(); + const client = makeClient({ + userId: "user-1", + consentFactory: async () => consent, + }); + await client.consent.grant.mutate({ category: "marketing" }); + const res = await client.consent.isGranted.query({ + category: "marketing", + }); + expect(res).toEqual({ granted: true }); + }); +}); + describe("consentRouter — error passthrough", () => { it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => { const brokenConsent: IConsent = { diff --git a/packages/core-consent/src/consent.router.ts b/packages/core-consent/src/consent.router.ts index 9ecc8c9..a0750cd 100644 --- a/packages/core-consent/src/consent.router.ts +++ b/packages/core-consent/src/consent.router.ts @@ -1,5 +1,6 @@ -import { initTRPC } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import type { ConsentFactory } from "./di/bind-production"; @@ -26,41 +27,57 @@ export type ConsentRouterContext = { consentFactory: ConsentFactory; }; -const tc = initTRPC.context().create(); - -const consentProcedure = tc.procedure +/** + * Consent procedures build on the SHARED `t` instance from + * `@repo/core-shared/trpc/init` (audit finding A10): the app router is created + * with the superjson transformer, and a router built from a private `initTRPC` + * without superjson would corrupt every input/output that crosses the wire. + * + * The shared `t` is context-untyped, so the middleware narrows `ctx` to + * `ConsentRouterContext` at runtime — same cast pattern as the dsr router. + */ +const consentProcedure = t.procedure .use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]])) .use(async ({ ctx, next }) => { - if (!ctx.userId) throw new UnauthenticatedError(); - return next(); + const { userId, consentFactory } = ctx as Partial; + if (!userId) throw new UnauthenticatedError(); + if (!consentFactory) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "consentFactory missing from tRPC context — wire the binding from " + + "bindProductionConsent/bindDevSeedConsent into createContext", + }); + } + return next({ ctx: { ...ctx, userId, consentFactory } }); }); -export const consentRouter = tc.router({ +export const consentRouter = t.router({ grant: consentProcedure .input(grantHandlerInputSchema) .mutation(async ({ ctx, input }) => { - const consent = await ctx.consentFactory(ctx.userId!); + const consent = await ctx.consentFactory(ctx.userId); return grantHandler(consent, input); }), withdraw: consentProcedure .input(withdrawHandlerInputSchema) .mutation(async ({ ctx, input }) => { - const consent = await ctx.consentFactory(ctx.userId!); + const consent = await ctx.consentFactory(ctx.userId); return withdrawHandler(consent, input); }), isGranted: consentProcedure .input(isGrantedHandlerInputSchema) .query(async ({ ctx, input }) => { - const consent = await ctx.consentFactory(ctx.userId!); + const consent = await ctx.consentFactory(ctx.userId); return isGrantedHandler(consent, input); }), getCategories: consentProcedure .input(z.object({}).strict()) .query(async ({ ctx }) => { - const consent = await ctx.consentFactory(ctx.userId!); + const consent = await ctx.consentFactory(ctx.userId); return getCategoriesHandler(consent); }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6100c4..4b343f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -674,6 +674,9 @@ importers: "@testing-library/react": specifier: ^16.0.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + "@trpc/client": + specifier: ^11.18.0 + version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) "@types/react": specifier: ^19.0.0 version: 19.2.14 @@ -689,6 +692,9 @@ importers: react: specifier: ^19.0.0 version: 19.2.4 + superjson: + specifier: ^2.2.1 + version: 2.2.6 typescript: specifier: ^5.8.0 version: 5.9.3