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 { ArticleNotFoundError } from "../../entities/errors/article";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
@@ -10,3 +11,10 @@ export const blogProcedure = t.procedure.use(
|
||||
[ArticleNotFoundError, "NOT_FOUND"],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Base procedure for MUTATING blog routes (audit finding B7): anonymous
|
||||
* callers are rejected with UNAUTHORIZED before the controller runs.
|
||||
* Read-only queries stay on `blogProcedure`.
|
||||
*/
|
||||
export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
|
||||
|
||||
@@ -30,7 +30,10 @@ describe("blogRouter", () => {
|
||||
});
|
||||
|
||||
it("createArticle then articleBySlug returns the article", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
|
||||
const caller = blogRouter.createCaller({
|
||||
user: { id: "u1", roles: [] },
|
||||
});
|
||||
|
||||
const created = await caller.createArticle({
|
||||
title: "Router Test Article",
|
||||
@@ -45,6 +48,34 @@ describe("blogRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blogRouter authorization (B7)", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("createArticle rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
await expect(
|
||||
caller.createArticle({
|
||||
title: "Nope",
|
||||
content: null,
|
||||
authorId: "u1",
|
||||
slug: "nope",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("read-only queries stay public", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
await expect(caller.listArticles({})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blogRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { IGetArticlesController } from "../../interface-adapters/controller
|
||||
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
|
||||
import { blogProcedure } from "./procedures";
|
||||
import { blogProcedure, blogProtectedProcedure } from "./procedures";
|
||||
|
||||
export const blogRouter = router({
|
||||
articleBySlug: blogProcedure
|
||||
@@ -32,7 +32,8 @@ export const blogRouter = router({
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
createArticle: blogProcedure
|
||||
// Mutations require an authenticated caller (B7).
|
||||
createArticle: blogProtectedProcedure
|
||||
.input(createArticleInputSchema)
|
||||
.mutation(({ input }) => {
|
||||
const ctrl = blogContainer.get<ICreateArticleController>(
|
||||
|
||||
Reference in New Issue
Block a user