feat(core-shared): auth-gate mutating feature procedures
Adds a shared requireAuthenticated tRPC middleware (reads the server- resolved ctx.user from createTrpcContext) and applies it to every mutating feature procedure — blog.createArticle and media.deleteMedia were anonymous-callable (audit finding B7). Read-only queries stay public; features compose <x>ProtectedProcedure from their error-mapped base procedure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated";
|
||||
|
||||
import { MediaNotFoundError } from "../../entities/errors/media";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
@@ -10,3 +11,10 @@ export const mediaProcedure = t.procedure.use(
|
||||
[MediaNotFoundError, "NOT_FOUND"],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Base procedure for MUTATING media routes (audit finding B7): anonymous
|
||||
* callers are rejected with UNAUTHORIZED before the controller runs.
|
||||
* Read-only queries stay on `mediaProcedure`.
|
||||
*/
|
||||
export const mediaProtectedProcedure = mediaProcedure.use(requireAuthenticated);
|
||||
|
||||
@@ -36,6 +36,29 @@ describe("mediaRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRouter authorization (B7)", () => {
|
||||
beforeEach(() => {
|
||||
mediaContainer.unbindAll();
|
||||
mediaContainer.load(MediaModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mediaContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("deleteMedia rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||
const caller = mediaRouter.createCaller({});
|
||||
await expect(caller.deleteMedia({ id: "some-id" })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("read-only queries stay public", async () => {
|
||||
const caller = mediaRouter.createCaller({});
|
||||
await expect(caller.listMedia({})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
mediaContainer.unbindAll();
|
||||
@@ -69,7 +92,8 @@ describe("mediaRouter error mapping", () => {
|
||||
});
|
||||
|
||||
it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => {
|
||||
const caller = mediaRouter.createCaller({});
|
||||
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
|
||||
const caller = mediaRouter.createCaller({ user: { id: "u1", roles: [] } });
|
||||
try {
|
||||
await caller.deleteMedia({ id: "nonexistent-id" });
|
||||
throw new Error("expected throw");
|
||||
|
||||
@@ -11,21 +11,30 @@ import type { IGetMediaController } from "../../interface-adapters/controllers/g
|
||||
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
|
||||
import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller";
|
||||
|
||||
import { mediaProcedure } from "./procedures";
|
||||
import { mediaProcedure, mediaProtectedProcedure } from "./procedures";
|
||||
|
||||
export const mediaRouter = router({
|
||||
getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => {
|
||||
const ctrl = mediaContainer.get<IGetMediaController>(MEDIA_SYMBOLS.IGetMediaController);
|
||||
const ctrl = mediaContainer.get<IGetMediaController>(
|
||||
MEDIA_SYMBOLS.IGetMediaController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
|
||||
const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
|
||||
const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
|
||||
const ctrl = mediaContainer.get<IListMediaController>(
|
||||
MEDIA_SYMBOLS.IListMediaController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
// Mutations require an authenticated caller (B7).
|
||||
deleteMedia: mediaProtectedProcedure
|
||||
.input(deleteMediaInputSchema)
|
||||
.mutation(({ input }) => {
|
||||
const ctrl = mediaContainer.get<IDeleteMediaController>(
|
||||
MEDIA_SYMBOLS.IDeleteMediaController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
});
|
||||
|
||||
export type MediaRouter = typeof mediaRouter;
|
||||
|
||||
Reference in New Issue
Block a user