348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
// @ts-nocheck
|
|
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";
|
|
import { uploadMedia } from "./admin-media";
|
|
import { deleteFavicon, saveFavicon } from "./save-favicon";
|
|
import { saveLogo } from "./save-logo";
|
|
|
|
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 () => ({
|
|
before: null,
|
|
after: { value: "/api/media/x" },
|
|
output: { url: "/api/media/x" },
|
|
})),
|
|
}));
|
|
|
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
|
contentMutationService: { execute },
|
|
createContentMutationInvocation: (actor, correlationId) => ({
|
|
expectedActorId: actor.id,
|
|
correlationId,
|
|
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",
|
|
() => ({
|
|
executeLegacyBrandAssetMutation,
|
|
}),
|
|
);
|
|
vi.mock("@/lib/admin/guard", () => ({
|
|
requirePermission: vi.fn(),
|
|
requireStaff: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/safe-action", () => ({
|
|
adminAction: (_options: unknown, handler: unknown) => handler,
|
|
}));
|
|
vi.mock("@/lib/safe-action-shared", () => ({
|
|
ActionError: class ActionError extends Error {},
|
|
actionOk: (data: unknown = {}) => ({ ok: true, data }),
|
|
}));
|
|
vi.mock("@/lib/logger", () => ({
|
|
logger: { error: vi.fn() },
|
|
}));
|
|
vi.mock("@/lib/permissions", () => ({
|
|
PERMS: {
|
|
NEWS_EDIT: "news.edit",
|
|
PAGES_EDIT: "pages.edit",
|
|
SETTINGS_EDIT: "settings.edit",
|
|
SETTINGS_VIEW: "settings.view",
|
|
},
|
|
}));
|
|
vi.mock("@/lib/db", () => ({
|
|
db: {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => ({ limit: vi.fn(async () => []) })),
|
|
})),
|
|
})),
|
|
insert: vi.fn(() => ({
|
|
values: vi.fn(async () => [{ insertId: 1 }]),
|
|
})),
|
|
},
|
|
WebsiteArticles: { id: "id", slug: "slug" },
|
|
WebsiteAds: { id: "id" },
|
|
WebsiteSetting: { key: "key" },
|
|
}));
|
|
vi.mock("@/lib/services/staff-activity", () => ({
|
|
logStaffActivity: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/services/site-settings", () => ({
|
|
siteSettings: { get: vi.fn(), reload: vi.fn() },
|
|
}));
|
|
vi.mock("@/lib/media-storage", () => ({
|
|
MEDIA_ROOT: "C:\\media",
|
|
resolveMediaPath: vi.fn((name: string) => `C:\\media\\${name}`),
|
|
}));
|
|
vi.mock("node:fs/promises", () => ({
|
|
mkdir: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
unlink: vi.fn(),
|
|
}));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
|
|
|
const staff = { id: 42, rank: 7, username: "operator" };
|
|
const form = (data: Record<string, FormDataEntryValue>) => ({
|
|
get: (key: string) => data[key] ?? null,
|
|
has: (key: string) => key in data,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
|
vi.mocked(requireStaff).mockResolvedValue(staff as never);
|
|
execute.mockResolvedValue({
|
|
ok: true,
|
|
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", () => {
|
|
it("delegates article creation and preserves redirect ordering", async () => {
|
|
await createArticle(
|
|
form({
|
|
title: "Launch",
|
|
shortStory: "Summary",
|
|
fullStory: "Body",
|
|
image: "/image.png",
|
|
}) as FormData,
|
|
);
|
|
expect(execute).toHaveBeenCalledWith(
|
|
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
|
"article.change",
|
|
expect.objectContaining({ action: "create", title: "Launch" }),
|
|
);
|
|
expect(redirect).toHaveBeenCalledWith("/admin/articles");
|
|
});
|
|
|
|
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
|
expect(
|
|
await createAd(
|
|
form({ image: "https://example.test/ad.png" }) as FormData,
|
|
),
|
|
).toBeUndefined();
|
|
expect(execute).toHaveBeenCalledWith(
|
|
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
|
"ad.change",
|
|
expect.objectContaining({ action: "create" }),
|
|
);
|
|
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
|
});
|
|
|
|
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
|
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
|
const media = await uploadMedia(form({ file }) as FormData);
|
|
const favicon = await saveFavicon(form({ file }) as FormData);
|
|
expect(media).toEqual({ ok: true });
|
|
expect(favicon).toEqual({ success: true, url: "/api/media/x" });
|
|
expect(execute).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"media.upload",
|
|
expect.objectContaining({ file }),
|
|
);
|
|
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 () => {
|
|
vi.clearAllMocks();
|
|
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
|
await saveFavicon(form({ file }) as FormData);
|
|
await deleteFavicon();
|
|
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(
|
|
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 () => {
|
|
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
|
vi.mocked(requirePermission).mockRejectedValueOnce(
|
|
new Error("favicon denied"),
|
|
);
|
|
await expect(saveFavicon(form({ file }) as FormData)).rejects.toThrow(
|
|
"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("maps a favicon audit partial to the truthful legacy result shape", async () => {
|
|
auditedBrandExecute.mockResolvedValueOnce({
|
|
before: { value: "/old.ico" },
|
|
after: { value: "/api/media/favicon/new.ico" },
|
|
output: { url: "/api/media/favicon/new.ico" },
|
|
completion: {
|
|
status: "partial",
|
|
external: "completed",
|
|
audit: "unavailable",
|
|
},
|
|
});
|
|
const file = new File(["bytes"], "favicon.png", { type: "image/png" });
|
|
await expect(saveFavicon(form({ file }) as FormData)).resolves.toEqual({
|
|
success: false,
|
|
url: "/api/media/favicon/new.ico",
|
|
error:
|
|
"Favicon change completed partially; verify storage and audit state",
|
|
});
|
|
});
|
|
|
|
it("maps a logo audit partial to the truthful legacy result shape", async () => {
|
|
auditedBrandExecute.mockResolvedValueOnce({
|
|
before: { value: "/old.png" },
|
|
after: { value: "/api/media/logo/new.png" },
|
|
output: { url: "/api/media/logo/new.png" },
|
|
completion: {
|
|
status: "partial",
|
|
external: "completed",
|
|
audit: "unavailable",
|
|
},
|
|
});
|
|
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
|
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
|
success: false,
|
|
url: "/api/media/logo/new.png",
|
|
error: "Logo change completed partially; verify storage and audit state",
|
|
});
|
|
});
|
|
|
|
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
|
for (const path of [
|
|
"src/actions/admin-ads.ts",
|
|
"src/actions/admin-articles.ts",
|
|
"src/actions/admin-banners.ts",
|
|
"src/actions/admin-email-templates.ts",
|
|
"src/actions/admin-help.ts",
|
|
"src/actions/admin-media.ts",
|
|
"src/actions/admin-nav-menu.ts",
|
|
"src/actions/admin-photos.ts",
|
|
"src/actions/admin-tags.ts",
|
|
"src/actions/admin-theme.ts",
|
|
"src/actions/admin-writeable-boxes.ts",
|
|
"src/actions/banners.ts",
|
|
"src/actions/events.ts",
|
|
"src/actions/polls.ts",
|
|
"src/actions/prefixes.ts",
|
|
"src/actions/save-favicon.ts",
|
|
"src/actions/save-logo.ts",
|
|
"src/actions/translations.ts",
|
|
"src/actions/emulator.ts",
|
|
]) {
|
|
const source = readFileSync(path, "utf8");
|
|
expect(
|
|
source.includes("contentMutationService") ||
|
|
source.includes("contentProductionMutationAdapter") ||
|
|
source.includes('from "./banners"'),
|
|
path,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("routes the real banner manager through the required compatibility action", () => {
|
|
const source = readFileSync(
|
|
"src/app/admin/banners/banners-manager.tsx",
|
|
"utf8",
|
|
);
|
|
expect(source).toContain('from "@/actions/admin-banners"');
|
|
expect(source).not.toContain('from "@/actions/banners"');
|
|
});
|
|
});
|