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-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@trpc/client": "^11.18.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.7",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { TRPCError } from "@trpc/server";
|
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 { RecordingConsent } from "@repo/core-testing/instrumentation";
|
||||||
|
import { router } from "@repo/core-shared/trpc/init";
|
||||||
import { consentRouter } from "@/consent.router";
|
import { consentRouter } from "@/consent.router";
|
||||||
import type { ConsentRouterContext } from "@/consent.router";
|
import type { ConsentRouterContext } from "@/consent.router";
|
||||||
import type { IConsent } from "@/consent.interface";
|
import type { IConsent } from "@/consent.interface";
|
||||||
|
import { InMemoryConsent } from "@/in-memory-consent";
|
||||||
|
|
||||||
function makeContext(
|
function makeContext(
|
||||||
consent: RecordingConsent,
|
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", () => {
|
describe("consentRouter — error passthrough", () => {
|
||||||
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
||||||
const brokenConsent: IConsent = {
|
const brokenConsent: IConsent = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { initTRPC } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { t } from "@repo/core-shared/trpc/init";
|
||||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||||
|
|
||||||
import type { ConsentFactory } from "./di/bind-production";
|
import type { ConsentFactory } from "./di/bind-production";
|
||||||
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
|
|||||||
consentFactory: ConsentFactory;
|
consentFactory: ConsentFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tc = initTRPC.context<ConsentRouterContext>().create();
|
/**
|
||||||
|
* Consent procedures build on the SHARED `t` instance from
|
||||||
const consentProcedure = tc.procedure
|
* `@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(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
|
||||||
.use(async ({ ctx, next }) => {
|
.use(async ({ ctx, next }) => {
|
||||||
if (!ctx.userId) throw new UnauthenticatedError();
|
const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
|
||||||
return next();
|
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
|
grant: consentProcedure
|
||||||
.input(grantHandlerInputSchema)
|
.input(grantHandlerInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return grantHandler(consent, input);
|
return grantHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
withdraw: consentProcedure
|
withdraw: consentProcedure
|
||||||
.input(withdrawHandlerInputSchema)
|
.input(withdrawHandlerInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return withdrawHandler(consent, input);
|
return withdrawHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
isGranted: consentProcedure
|
isGranted: consentProcedure
|
||||||
.input(isGrantedHandlerInputSchema)
|
.input(isGrantedHandlerInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return isGrantedHandler(consent, input);
|
return isGrantedHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getCategories: consentProcedure
|
getCategories: consentProcedure
|
||||||
.input(z.object({}).strict())
|
.input(z.object({}).strict())
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return getCategoriesHandler(consent);
|
return getCategoriesHandler(consent);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -674,6 +674,9 @@ importers:
|
|||||||
"@testing-library/react":
|
"@testing-library/react":
|
||||||
specifier: ^16.0.0
|
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)
|
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":
|
"@types/react":
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -689,6 +692,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
|
superjson:
|
||||||
|
specifier: ^2.2.1
|
||||||
|
version: 2.2.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user