diff --git a/src/actions/content-legacy-parity.test.ts b/src/actions/content-legacy-parity.test.ts index ea44832a..43421653 100644 --- a/src/actions/content-legacy-parity.test.ts +++ b/src/actions/content-legacy-parity.test.ts @@ -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); diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts index 043c2480..a1b25ed1 100644 --- a/src/actions/save-favicon.ts +++ b/src/actions/save-favicon.ts @@ -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 { + 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) { diff --git a/src/actions/save-logo.ts b/src/actions/save-logo.ts index 74dac128..04479c9a 100644 --- a/src/actions/save-logo.ts +++ b/src/actions/save-logo.ts @@ -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 { + 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, diff --git a/src/app/api/media/[...path]/route.test.ts b/src/app/api/media/[...path]/route.test.ts new file mode 100644 index 00000000..c7c35dd2 --- /dev/null +++ b/src/app/api/media/[...path]/route.test.ts @@ -0,0 +1,34 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +vi.mock("node:fs", () => ({ existsSync: vi.fn() })); +vi.mock("node:fs/promises", () => ({ readFile: vi.fn() })); +vi.mock("@/lib/media-storage", () => ({ + MEDIA_ROOT: "C:\\media", + resolveMediaPath: vi.fn( + (name: string) => `C:\\media\\${name.replaceAll("/", "\\")}`, + ), +})); + +describe("GET /api/media/[...path]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue(Buffer.from([0, 0, 1, 0])); + }); + + it("serves a stored genuine ICO with the canonical MIME and nosniff", async () => { + const response = await GET( + new Request("http://localhost/api/media/favicon/site.ico"), + { + params: Promise.resolve({ path: ["favicon", "site.ico"] }), + }, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/x-icon"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(readFile).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/api/media/[...path]/route.ts b/src/app/api/media/[...path]/route.ts index b0bea0c3..a14b5f26 100644 --- a/src/app/api/media/[...path]/route.ts +++ b/src/app/api/media/[...path]/route.ts @@ -4,7 +4,16 @@ import path from "node:path"; import { NextResponse } from "next/server"; import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage"; -const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"]; +const ALLOWED_EXT = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".ico", +]; export async function GET( _request: Request, @@ -41,6 +50,7 @@ export async function GET( ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", + ".ico": "image/x-icon", }; return new NextResponse(bytes, { diff --git a/src/features/housekeeping/domains/content/content-providers-production.test.ts b/src/features/housekeeping/domains/content/content-providers-production.test.ts index e83a0327..bc1399e6 100644 --- a/src/features/housekeeping/domains/content/content-providers-production.test.ts +++ b/src/features/housekeeping/domains/content/content-providers-production.test.ts @@ -142,6 +142,21 @@ describe("Content production providers", () => { ).rejects.toThrow("Content widget query unavailable"); }); + it("marks an inbox dependency unavailable instead of returning an available empty list", async () => { + run.mockResolvedValueOnce({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" }, + correlationId: "unavailable", + }); + await expect( + loadContentInboxItems( + "publication", + context, + new AbortController().signal, + ), + ).rejects.toThrow("Content inbox query unavailable"); + }); + it("emits only actionable publication statuses", async () => { run.mockResolvedValueOnce( result("content.editorial.articles", 2, [ diff --git a/src/features/housekeeping/domains/content/inbox-production.ts b/src/features/housekeeping/domains/content/inbox-production.ts index b27013ad..51f59200 100644 --- a/src/features/housekeeping/domains/content/inbox-production.ts +++ b/src/features/housekeeping/domains/content/inbox-production.ts @@ -53,7 +53,7 @@ export async function loadContentInboxItems( routeId, list: { pageSize: 25, offset: 0 }, }); - if (!result.ok) continue; + if (!result.ok) throw new Error("Content inbox query unavailable"); for (const item of result.data.items) { const status = item.status?.toLocaleLowerCase() ?? ""; if (!ACTIONABLE_STATUS[kind].has(status)) continue; diff --git a/src/features/housekeeping/domains/content/pages/content-command-form.tsx b/src/features/housekeeping/domains/content/pages/content-command-form.tsx index a4c7dfae..8d587784 100644 --- a/src/features/housekeeping/domains/content/pages/content-command-form.tsx +++ b/src/features/housekeeping/domains/content/pages/content-command-form.tsx @@ -19,7 +19,7 @@ export interface ContentCommandField { readonly min?: number; readonly max?: number; readonly maxLength?: number; - readonly defaultValue?: string | number; + readonly defaultValue?: string | number | boolean; readonly options?: readonly Readonly<{ value: string | number; label: string; @@ -41,8 +41,8 @@ const OMIT_FIELD = Symbol("omit optional Content command field"); function parseField(field: ContentCommandField, formData: FormData): unknown { const rawValue = formData.get(field.name); - if (rawValue === null && !field.required) return OMIT_FIELD; if (field.type === "checkbox") return rawValue === "on"; + if (rawValue === null && !field.required) return OMIT_FIELD; if (field.type === "file") return rawValue instanceof File ? rawValue : null; const raw = String(rawValue ?? "") .normalize("NFC") @@ -136,6 +136,7 @@ export function ContentCommandForm({ id={`${commandId}-${field.name}`} name={field.name} type="checkbox" + defaultChecked={field.defaultValue === true} />{" "} {field.label} @@ -145,7 +146,11 @@ export function ContentCommandForm({