fix(housekeeping): close final authorization gaps
This commit is contained in:
1 parent
2b8f73a91d
commit
222535e116
9 files changed
+219
-44
No files matched your search
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { requirePermission, requireStaff } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { createAd } from "./admin-ads";
|
||||
import { createArticle } from "./admin-articles";
|
||||
import { uploadMedia } from "./admin-media";
|
||||
@@ -192,7 +193,7 @@ describe("Content compatibility wrappers", () => {
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the legacy favicon page gate and establishes a staff logo floor", async () => {
|
||||
it("preserves the favicon page gate and requires settings edit for logo mutation", async () => {
|
||||
vi.clearAllMocks();
|
||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
||||
await saveFavicon(form({ file }) as FormData);
|
||||
@@ -200,8 +201,8 @@ describe("Content compatibility wrappers", () => {
|
||||
await saveLogo(form({ file }) as FormData);
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(1, "settings.view");
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(2, "settings.view");
|
||||
expect(requirePermission).not.toHaveBeenCalledWith("settings.edit");
|
||||
expect(requireStaff).toHaveBeenCalledOnce();
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(3, PERMS.SETTINGS_EDIT);
|
||||
expect(requireStaff).not.toHaveBeenCalled();
|
||||
expect(
|
||||
auditedBrandExecute.mock.calls.map(([operation]) => operation),
|
||||
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
|
||||
@@ -219,7 +220,9 @@ describe("Content compatibility wrappers", () => {
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(requireStaff).mockRejectedValueOnce(new Error("logo denied"));
|
||||
vi.mocked(requirePermission).mockRejectedValueOnce(
|
||||
new Error("logo denied"),
|
||||
);
|
||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
||||
"logo denied",
|
||||
);
|
||||
@@ -227,26 +230,18 @@ describe("Content compatibility wrappers", () => {
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a requireStaff-approved actor without an additional ACL use the audited logo boundary", async () => {
|
||||
it("does not let a staff-only actor bypass the logo settings ACL", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireStaff).mockResolvedValue(staff as never);
|
||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
||||
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
||||
success: true,
|
||||
url: "/api/media/x",
|
||||
});
|
||||
expect(requirePermission).not.toHaveBeenCalled();
|
||||
expect(requireStaff).toHaveBeenCalledOnce();
|
||||
expect(auditedBrandExecute).toHaveBeenCalledWith(
|
||||
"logo.save",
|
||||
{ file },
|
||||
expect.objectContaining({
|
||||
capability: expect.objectContaining({
|
||||
actor: expect.objectContaining({ id: 42 }),
|
||||
}),
|
||||
legacy: true,
|
||||
}),
|
||||
vi.mocked(requirePermission).mockRejectedValueOnce(
|
||||
new Error("settings edit denied"),
|
||||
);
|
||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
||||
"settings edit denied",
|
||||
);
|
||||
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
|
||||
expect(requireStaff).not.toHaveBeenCalled();
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a brand mutation when the rehydrated actor changes after the legacy guard", async () => {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ContentMutationSnapshot } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requireStaff, type StaffUser } from "@/lib/admin/guard";
|
||||
import { requirePermission, type StaffUser } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const PARTIAL_ERROR =
|
||||
"Logo change completed partially; verify storage and audit state";
|
||||
@@ -34,7 +35,7 @@ async function executeAuditedLogoMutation(
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
|
||||
Reference in new issue
Block a user