fix(housekeeping): address task 14 review round 1

This commit is contained in:
Simo committed 2026-08-30 10:53:50 +02:00
1 parent fd68819d9b
commit d09eaa33d6
23 files changed
+2503 -488

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"use server";
export { createBanner, deleteBanner, updateBanner } from "./banners";
+47 -5
View File
@@ -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);
});
});
+69 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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",
};
}
}
+18 -20
View File
@@ -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");
});
});