fix(core-consent): build consent router from the shared superjson t

The router previously created its own initTRPC without superjson while
the app router uses the shared transformer-enabled instance — a wire
transformer mismatch that corrupts inputs (audit finding A10). Procedures
now build on @repo/core-shared/trpc/init's t; a real client+fetch-adapter
round-trip test pins Date revival through appRouter-style mounting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:46:46 +02:00
parent e2a4657471
commit 8b78563881
4 changed files with 112 additions and 11 deletions

View File

@@ -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",

View File

@@ -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<typeof appLikeRouter>({
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 = {

View File

@@ -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<ConsentRouterContext>().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<ConsentRouterContext>;
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);
}),
});