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:
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user