fix(housekeeping): address task 14 review round 2
This commit is contained in:
1 parent
d09eaa33d6
commit
c524b305d7
17 files changed
+614
-130
No files matched your search
@@ -2,6 +2,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
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 { createAd } from "./admin-ads";
|
||||
import { createArticle } from "./admin-articles";
|
||||
@@ -9,12 +10,17 @@ import { uploadMedia } from "./admin-media";
|
||||
import { deleteFavicon, saveFavicon } from "./save-favicon";
|
||||
import { saveLogo } from "./save-logo";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({
|
||||
const { execute, auditedBrandExecute } = vi.hoisted(() => ({
|
||||
execute: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
correlationId: "legacy",
|
||||
})),
|
||||
auditedBrandExecute: vi.fn(async () => ({
|
||||
before: null,
|
||||
after: { value: "/api/media/x" },
|
||||
output: { url: "/api/media/x" },
|
||||
})),
|
||||
}));
|
||||
const { executeLegacyBrandAssetMutation } = vi.hoisted(() => ({
|
||||
executeLegacyBrandAssetMutation: vi.fn(async () => ({
|
||||
@@ -32,6 +38,21 @@ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/features/housekeeping/domains/content/services/mutations-production",
|
||||
() => ({
|
||||
contentProductionMutationAdapter: { execute: auditedBrandExecute },
|
||||
}),
|
||||
);
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: vi.fn(async () => ({
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
})),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/features/housekeeping/domains/content/services/mutation-runtime-external",
|
||||
() => ({
|
||||
@@ -108,6 +129,11 @@ beforeEach(() => {
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
correlationId: "legacy",
|
||||
});
|
||||
auditedBrandExecute.mockResolvedValue({
|
||||
before: null,
|
||||
after: { value: "/api/media/x" },
|
||||
output: { url: "/api/media/x" },
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content legacy wrappers", () => {
|
||||
@@ -153,10 +179,17 @@ describe("Content legacy wrappers", () => {
|
||||
"media.upload",
|
||||
expect.objectContaining({ file }),
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).toHaveBeenCalledWith(
|
||||
expect(auditedBrandExecute).toHaveBeenCalledWith(
|
||||
"favicon.save",
|
||||
{ file },
|
||||
expect.objectContaining({
|
||||
capability: expect.objectContaining({
|
||||
actor: expect.objectContaining({ id: 42 }),
|
||||
}),
|
||||
legacy: true,
|
||||
}),
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the legacy favicon page gate and establishes a staff logo floor", async () => {
|
||||
@@ -170,10 +203,9 @@ describe("Content legacy wrappers", () => {
|
||||
expect(requirePermission).not.toHaveBeenCalledWith("settings.edit");
|
||||
expect(requireStaff).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
executeLegacyBrandAssetMutation.mock.calls.map(
|
||||
([operation]) => operation,
|
||||
),
|
||||
auditedBrandExecute.mock.calls.map(([operation]) => operation),
|
||||
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mutate brand assets when either legacy guard denies access", async () => {
|
||||
@@ -185,12 +217,52 @@ describe("Content legacy wrappers", () => {
|
||||
"favicon denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(requireStaff).mockRejectedValueOnce(new Error("logo denied"));
|
||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
||||
"logo denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a requireStaff-approved actor without an additional ACL use the audited logo boundary", 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a brand mutation when the rehydrated actor changes after the legacy guard", async () => {
|
||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
|
||||
actor: { id: 99, username: "other", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
} as never);
|
||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
||||
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Authenticated staff changed during logo mutation",
|
||||
});
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
||||
@@ -218,7 +290,7 @@ describe("Content legacy wrappers", () => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(
|
||||
source.includes("contentMutationService") ||
|
||||
source.includes("executeLegacyBrandAssetMutation") ||
|
||||
source.includes("contentProductionMutationAdapter") ||
|
||||
source.includes('from "./banners"'),
|
||||
path,
|
||||
).toBe(true);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import type { ContentMutationSnapshot } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission, type StaffUser } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024;
|
||||
@@ -15,10 +16,34 @@ const ALLOWED = [
|
||||
"image/svg+xml",
|
||||
];
|
||||
|
||||
async function executeAuditedFaviconMutation(
|
||||
staff: StaffUser,
|
||||
operation: "favicon.save" | "favicon.delete",
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const [
|
||||
{ contentProductionMutationAdapter },
|
||||
{ getHousekeepingCapabilityContext },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
"@/features/housekeeping/domains/content/services/mutations-production"
|
||||
),
|
||||
import("@/features/housekeeping/foundation/server-capability-context"),
|
||||
]);
|
||||
const capability = await getHousekeepingCapabilityContext();
|
||||
if (capability.actor.id !== staff.id)
|
||||
throw new Error("Authenticated staff changed during favicon mutation");
|
||||
return contentProductionMutationAdapter.execute(operation, input, {
|
||||
capability,
|
||||
correlationId: createCorrelationId(),
|
||||
legacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0)
|
||||
@@ -30,7 +55,7 @@ export async function saveFavicon(
|
||||
success: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||
};
|
||||
const result = await executeLegacyBrandAssetMutation("favicon.save", {
|
||||
const result = await executeAuditedFaviconMutation(staff, "favicon.save", {
|
||||
file,
|
||||
});
|
||||
siteRevalidate();
|
||||
@@ -52,9 +77,9 @@ export async function deleteFavicon(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
await executeLegacyBrandAssetMutation("favicon.delete", {});
|
||||
await executeAuditedFaviconMutation(staff, "favicon.delete", {});
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
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";
|
||||
|
||||
async function executeAuditedLogoMutation(
|
||||
staff: StaffUser,
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const [
|
||||
{ contentProductionMutationAdapter },
|
||||
{ getHousekeepingCapabilityContext },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
"@/features/housekeeping/domains/content/services/mutations-production"
|
||||
),
|
||||
import("@/features/housekeeping/foundation/server-capability-context"),
|
||||
]);
|
||||
const capability = await getHousekeepingCapabilityContext();
|
||||
if (capability.actor.id !== staff.id)
|
||||
throw new Error("Authenticated staff changed during logo mutation");
|
||||
return contentProductionMutationAdapter.execute("logo.save", input, {
|
||||
capability,
|
||||
correlationId: createCorrelationId(),
|
||||
legacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
const result = await executeLegacyBrandAssetMutation("logo.save", { file });
|
||||
const result = await executeAuditedLogoMutation(staff, { file });
|
||||
revalidatePath("/", "layout");
|
||||
return {
|
||||
success: true,
|
||||
|
||||
Reference in new issue
Block a user