fix(housekeeping): address task 14 review round 1
This commit is contained in:
1 parent
fd68819d9b
commit
d09eaa33d6
23 files changed
+2503
-488
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"use server";
|
||||
|
||||
export { createBanner, deleteBanner, updateBanner } from "./banners";
|
||||
@@ -1,25 +1,44 @@
|
||||
// @ts-nocheck
|
||||
import { readFileSync } from "node:fs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { deletePhoto } from "./admin-photos";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
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", () => ({
|
||||
contentMutationService: { execute },
|
||||
createContentMutationInvocation: (actor, correlationId) => ({
|
||||
expectedActorId: actor.id,
|
||||
correlationId,
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: { id: 42 }, after: null },
|
||||
correlationId: "legacy",
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePhoto", () => {
|
||||
it("delegates deletion and preserves both revalidations", async () => {
|
||||
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
||||
await deletePhoto({ get: (key) => (key === "id" ? "42" : null) });
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
||||
id: 42,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
@@ -28,3 +47,26 @@ describe("deletePhoto", () => {
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin-photos extracted runtime contract", () => {
|
||||
it("keeps the wrapper and owning runtime responsible for purge and audit", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(runtime).toContain("logStaffActivity");
|
||||
});
|
||||
|
||||
it("rejects traversal and remote photo purge targets", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { requirePermission, requireStaff } from "@/lib/admin/guard";
|
||||
import { createAd } from "./admin-ads";
|
||||
import { createArticle } from "./admin-articles";
|
||||
import { uploadMedia } from "./admin-media";
|
||||
import { saveFavicon } from "./save-favicon";
|
||||
import { deleteFavicon, saveFavicon } from "./save-favicon";
|
||||
import { saveLogo } from "./save-logo";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({
|
||||
execute: vi.fn(async () => ({
|
||||
@@ -15,6 +16,13 @@ const { execute } = vi.hoisted(() => ({
|
||||
correlationId: "legacy",
|
||||
})),
|
||||
}));
|
||||
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 },
|
||||
@@ -24,7 +32,16 @@ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
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,
|
||||
}));
|
||||
@@ -40,6 +57,7 @@ vi.mock("@/lib/permissions", () => ({
|
||||
NEWS_EDIT: "news.edit",
|
||||
PAGES_EDIT: "pages.edit",
|
||||
SETTINGS_EDIT: "settings.edit",
|
||||
SETTINGS_VIEW: "settings.view",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -84,6 +102,7 @@ const form = (data: Record<string, FormDataEntryValue>) => ({
|
||||
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" } },
|
||||
@@ -111,7 +130,9 @@ describe("Content legacy wrappers", () => {
|
||||
|
||||
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||
expect(
|
||||
await createAd(form({ image: "https://example.test/ad.png" }) as FormData),
|
||||
await createAd(
|
||||
form({ image: "https://example.test/ad.png" }) as FormData,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
||||
@@ -132,17 +153,51 @@ describe("Content legacy wrappers", () => {
|
||||
"media.upload",
|
||||
expect.objectContaining({ file }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(executeLegacyBrandAssetMutation).toHaveBeenCalledWith(
|
||||
"favicon.save",
|
||||
expect.objectContaining({ file }),
|
||||
{ file },
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
executeLegacyBrandAssetMutation.mock.calls.map(
|
||||
([operation]) => operation,
|
||||
),
|
||||
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
vi.mocked(requireStaff).mockRejectedValueOnce(new Error("logo denied"));
|
||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
||||
"logo denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -160,9 +215,13 @@ describe("Content legacy wrappers", () => {
|
||||
"src/actions/translations.ts",
|
||||
"src/actions/emulator.ts",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"contentMutationService",
|
||||
);
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(
|
||||
source.includes("contentMutationService") ||
|
||||
source.includes("executeLegacyBrandAssetMutation") ||
|
||||
source.includes('from "./banners"'),
|
||||
path,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+55
-30
@@ -1,43 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024;
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
||||
const ALLOWED = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
];
|
||||
|
||||
export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"favicon.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
siteRevalidate();
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0)
|
||||
return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE)
|
||||
return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||
};
|
||||
const result = await executeLegacyBrandAssetMutation("favicon.save", {
|
||||
file,
|
||||
});
|
||||
siteRevalidate();
|
||||
return {
|
||||
success: true,
|
||||
...(typeof result.output?.url === "string"
|
||||
? { url: result.output.url }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"favicon.delete",
|
||||
{},
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
export async function deleteFavicon(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
await executeLegacyBrandAssetMutation("favicon.delete", {});
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function siteRevalidate(): void {
|
||||
|
||||
+23
-19
@@ -1,24 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
|
||||
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"logo.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
revalidatePath("/", "layout");
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
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 });
|
||||
revalidatePath("/", "layout");
|
||||
return {
|
||||
success: true,
|
||||
...(typeof result.output?.url === "string"
|
||||
? { url: result.output.url }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
|
||||
describe("admin-photos Content service contract", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
describe("setTradeLock database and live-sync contract", () => {
|
||||
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
const service = readFileSync(
|
||||
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||
"utf8",
|
||||
);
|
||||
const rcon = readFileSync("src/lib/services/rcon.ts", "utf8");
|
||||
|
||||
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
||||
expect(wrapper).toContain("contentMutationService.execute");
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||
expect(runtime).toContain("@/lib/db");
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
it("retains the legacy action while the owning service writes both trade-lock stores", () => {
|
||||
expect(wrapper).toMatch(/export async function setTradeLock/u);
|
||||
expect(wrapper).toContain('"user.trade-lock"');
|
||||
expect(service).toContain("UsersSettings");
|
||||
expect(service).toContain("Sanctions");
|
||||
expect(service).toContain("canTrade");
|
||||
expect(service).toContain("tradeLockedUntil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryRemoveLocalPhotoFile", () => {
|
||||
it("rejects path traversal and remote CDN urls", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||
it("keeps live RCON lock, alert, and disconnect behavior", () => {
|
||||
expect(rcon).toContain("settradelock");
|
||||
expect(rcon).toContain("setTradeLock(userId: number, locked: boolean)");
|
||||
expect(service).toContain("rcon.setTradeLock");
|
||||
expect(service).toContain("rcon.alertUser");
|
||||
expect(service).toContain("rcon.disconnectUser");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user