fix(housekeeping): close final authorization gaps

This commit is contained in:
Simo committed 2026-08-30 21:01:32 +02:00
1 parent 2b8f73a91d
commit 222535e116
9 files changed
+219 -44

No files matched your search

+17 -22
View File
@@ -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 -2
View File
@@ -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" };