fix(housekeeping): address task 14 review round 1
This commit is contained in:
1 parent
fd68819d9b
commit
d09eaa33d6
23 files changed
+2421
-406
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
export { createBanner, deleteBanner, updateBanner } from "./banners";
|
||||||
@@ -1,25 +1,44 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
import { deletePhoto } from "./admin-photos";
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
execute.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: { before: { id: 42 }, after: null },
|
||||||
|
correlationId: "legacy",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deletePhoto", () => {
|
describe("deletePhoto", () => {
|
||||||
it("delegates deletion and preserves both revalidations", async () => {
|
it("delegates deletion and preserves both revalidations", async () => {
|
||||||
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
await deletePhoto({ get: (key) => (key === "id" ? "42" : null) });
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
||||||
|
id: 42,
|
||||||
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||||
});
|
});
|
||||||
@@ -28,3 +47,26 @@ describe("deletePhoto", () => {
|
|||||||
expect(execute).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
+51
-26
@@ -1,43 +1,68 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||||
contentMutationService,
|
|
||||||
createContentMutationInvocation,
|
|
||||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MAX_SIZE = 2 * 1024 * 1024;
|
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 }> {
|
export async function saveFavicon(
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
formData: FormData,
|
||||||
|
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
try {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
if (!file || file.size === 0)
|
||||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
return { success: false, error: "No file provided" };
|
||||||
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
if (file.size > MAX_SIZE)
|
||||||
const result = await contentMutationService.execute(
|
return { success: false, error: "File too large (max 2MB)" };
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
if (!ALLOWED.includes(file.type))
|
||||||
"favicon.save",
|
return {
|
||||||
{ file },
|
success: false,
|
||||||
);
|
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
};
|
||||||
|
const result = await executeLegacyBrandAssetMutation("favicon.save", {
|
||||||
|
file,
|
||||||
|
});
|
||||||
siteRevalidate();
|
siteRevalidate();
|
||||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
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 }> {
|
export async function deleteFavicon(): Promise<{
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
success: boolean;
|
||||||
const result = await contentMutationService.execute(
|
error?: string;
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
}> {
|
||||||
"favicon.delete",
|
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
{},
|
try {
|
||||||
);
|
await executeLegacyBrandAssetMutation("favicon.delete", {});
|
||||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
|
||||||
siteRevalidate();
|
siteRevalidate();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function siteRevalidate(): void {
|
function siteRevalidate(): void {
|
||||||
|
|||||||
+20
-16
@@ -1,24 +1,28 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||||
contentMutationService,
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
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";
|
|
||||||
|
|
||||||
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
export async function saveLogo(
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
formData: FormData,
|
||||||
|
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
|
await requireStaff();
|
||||||
|
try {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file) return { success: false, error: "No file provided" };
|
if (!file) return { success: false, error: "No file provided" };
|
||||||
const result = await contentMutationService.execute(
|
const result = await executeLegacyBrandAssetMutation("logo.save", { file });
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
|
||||||
"logo.save",
|
|
||||||
{ file },
|
|
||||||
);
|
|
||||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
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 { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
|
||||||
|
|
||||||
describe("admin-photos Content service contract", () => {
|
describe("setTradeLock database and live-sync contract", () => {
|
||||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||||
const runtime = readFileSync(
|
const service = readFileSync(
|
||||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
const rcon = readFileSync("src/lib/services/rcon.ts", "utf8");
|
||||||
|
|
||||||
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
it("retains the legacy action while the owning service writes both trade-lock stores", () => {
|
||||||
expect(wrapper).toContain("contentMutationService.execute");
|
expect(wrapper).toMatch(/export async function setTradeLock/u);
|
||||||
expect(wrapper).toContain('"photo.delete"');
|
expect(wrapper).toContain('"user.trade-lock"');
|
||||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
expect(service).toContain("UsersSettings");
|
||||||
expect(runtime).toContain("@/lib/db");
|
expect(service).toContain("Sanctions");
|
||||||
expect(runtime).toContain("CameraWeb");
|
expect(service).toContain("canTrade");
|
||||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
expect(service).toContain("tradeLockedUntil");
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tryRemoveLocalPhotoFile", () => {
|
it("keeps live RCON lock, alert, and disconnect behavior", () => {
|
||||||
it("rejects path traversal and remote CDN urls", async () => {
|
expect(rcon).toContain("settradelock");
|
||||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
expect(rcon).toContain("setTradeLock(userId: number, locked: boolean)");
|
||||||
false,
|
expect(service).toContain("rcon.setTradeLock");
|
||||||
);
|
expect(service).toContain("rcon.alertUser");
|
||||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
expect(service).toContain("rcon.disconnectUser");
|
||||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -47,6 +47,13 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...(ext === ".svg"
|
||||||
|
? {
|
||||||
|
"Content-Security-Policy":
|
||||||
|
"sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -4,10 +4,7 @@ import {
|
|||||||
anyCapability,
|
anyCapability,
|
||||||
type HousekeepingCapabilityContext,
|
type HousekeepingCapabilityContext,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
import {
|
import { CONTENT_INBOX_SOURCE_IDS, createContentInboxSources } from "./inbox";
|
||||||
CONTENT_INBOX_SOURCE_IDS,
|
|
||||||
createContentInboxSources,
|
|
||||||
} from "./inbox";
|
|
||||||
import {
|
import {
|
||||||
CONTENT_SEARCH_PROVIDER_IDS,
|
CONTENT_SEARCH_PROVIDER_IDS,
|
||||||
createContentSearchProviders,
|
createContentSearchProviders,
|
||||||
@@ -74,7 +71,9 @@ describe("Content search providers", () => {
|
|||||||
"/ase/content/%255c..%255csystem",
|
"/ase/content/%255c..%255csystem",
|
||||||
"https://example.test/ase/content/editorial",
|
"https://example.test/ase/content/editorial",
|
||||||
"//example.test/ase/content/editorial",
|
"//example.test/ase/content/editorial",
|
||||||
])("rejects normalized and double-encoded traversal href %s", async (href) => {
|
])(
|
||||||
|
"rejects normalized and double-encoded traversal href %s",
|
||||||
|
async (href) => {
|
||||||
const adapters = {
|
const adapters = {
|
||||||
articles: async () => [
|
articles: async () => [
|
||||||
{
|
{
|
||||||
@@ -94,7 +93,8 @@ describe("Content search providers", () => {
|
|||||||
limit: 25,
|
limit: 25,
|
||||||
});
|
});
|
||||||
expect(result).toMatchObject({ ok: true, data: [] });
|
expect(result).toMatchObject({ ok: true, data: [] });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Content inbox and widgets", () => {
|
describe("Content inbox and widgets", () => {
|
||||||
@@ -119,6 +119,20 @@ describe("Content inbox and widgets", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not advertise media permissions for an event-and-poll attention source", async () => {
|
||||||
|
const attention = vi.fn(async () => []);
|
||||||
|
const [, source] = createContentInboxSources({
|
||||||
|
publication: async () => [],
|
||||||
|
attention,
|
||||||
|
});
|
||||||
|
const result = await source.getItems(
|
||||||
|
context([PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW]),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||||
|
expect(attention).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
|
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
|
||||||
const adapters = {
|
const adapters = {
|
||||||
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
|
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { contentQuery } from "./queries/content-queries";
|
|||||||
|
|
||||||
type ContentInboxKind = "publication" | "attention";
|
type ContentInboxKind = "publication" | "attention";
|
||||||
|
|
||||||
|
const ACTIONABLE_STATUS = {
|
||||||
|
publication: new Set(["draft", "scheduled", "pending", "failed"]),
|
||||||
|
attention: new Set(["draft", "cancelled", "closed", "failed"]),
|
||||||
|
} as const;
|
||||||
|
|
||||||
function time(value: string | null | undefined) {
|
function time(value: string | null | undefined) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const timestamp = Date.parse(value);
|
const timestamp = Date.parse(value);
|
||||||
@@ -31,7 +36,11 @@ export async function loadContentInboxItems(
|
|||||||
const definitions =
|
const definitions =
|
||||||
kind === "publication"
|
kind === "publication"
|
||||||
? ([
|
? ([
|
||||||
["content.editorial.articles", PERMS.NEWS_VIEW, "content.publication"],
|
[
|
||||||
|
"content.editorial.articles",
|
||||||
|
PERMS.NEWS_VIEW,
|
||||||
|
"content.publication",
|
||||||
|
],
|
||||||
] as const)
|
] as const)
|
||||||
: ([
|
: ([
|
||||||
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
|
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
|
||||||
@@ -39,12 +48,15 @@ export async function loadContentInboxItems(
|
|||||||
] as const);
|
] as const);
|
||||||
const items: HousekeepingWorkItem[] = [];
|
const items: HousekeepingWorkItem[] = [];
|
||||||
for (const [routeId, permission, sourceId] of definitions) {
|
for (const [routeId, permission, sourceId] of definitions) {
|
||||||
|
if (!context.has(permission)) continue;
|
||||||
const result = await contentQuery.run(context, {
|
const result = await contentQuery.run(context, {
|
||||||
routeId,
|
routeId,
|
||||||
list: { pageSize: 25, offset: 0 },
|
list: { pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
if (!result.ok) continue;
|
if (!result.ok) continue;
|
||||||
for (const item of result.data.items) {
|
for (const item of result.data.items) {
|
||||||
|
const status = item.status?.toLocaleLowerCase() ?? "";
|
||||||
|
if (!ACTIONABLE_STATUS[kind].has(status)) continue;
|
||||||
const date = time(item.updatedAt);
|
const date = time(item.updatedAt);
|
||||||
if (!date || !item.href) continue;
|
if (!date || !item.href) continue;
|
||||||
items.push({
|
items.push({
|
||||||
@@ -53,10 +65,11 @@ export async function loadContentInboxItems(
|
|||||||
deduplicationKey: sourceId + ":" + routeId + ":" + item.id,
|
deduplicationKey: sourceId + ":" + routeId + ":" + item.id,
|
||||||
domain: "content",
|
domain: "content",
|
||||||
capability: anyCapability(permission),
|
capability: anyCapability(permission),
|
||||||
severity: item.status === "failed" ? "warning" : "info",
|
severity:
|
||||||
priority: item.status === "failed" ? "high" : "normal",
|
status === "failed" || status === "cancelled" ? "warning" : "info",
|
||||||
|
priority: status === "failed" ? "high" : "normal",
|
||||||
...date,
|
...date,
|
||||||
state: item.status ?? "ready",
|
state: status,
|
||||||
titleKey: "pages.housekeeping.items.content",
|
titleKey: "pages.housekeeping.items.content",
|
||||||
context: { title: item.title },
|
context: { title: item.title },
|
||||||
href: item.href as `/ase/${string}`,
|
href: item.href as `/ase/${string}`,
|
||||||
|
|||||||
@@ -76,12 +76,7 @@ export function createContentInboxSources(
|
|||||||
),
|
),
|
||||||
createSource(
|
createSource(
|
||||||
"content.attention",
|
"content.attention",
|
||||||
anyCapability(
|
anyCapability(PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW),
|
||||||
PERMS.EVENTS_VIEW,
|
|
||||||
PERMS.POLLS_VIEW,
|
|
||||||
PERMS.PAGES_VIEW,
|
|
||||||
PERMS.BANNERS_VIEW,
|
|
||||||
),
|
|
||||||
adapters.attention,
|
adapters.attention,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,12 +37,19 @@ interface ContentCommandFormProps extends ContentCommandSubmission {
|
|||||||
readonly buttonLabel: string;
|
readonly buttonLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OMIT_FIELD = Symbol("omit optional Content command field");
|
||||||
|
|
||||||
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||||
const rawValue = formData.get(field.name);
|
const rawValue = formData.get(field.name);
|
||||||
|
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||||
if (field.type === "checkbox") return rawValue === "on";
|
if (field.type === "checkbox") return rawValue === "on";
|
||||||
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||||
const raw = String(rawValue ?? "").normalize("NFC").trim();
|
const raw = String(rawValue ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!raw && !field.required) return OMIT_FIELD;
|
||||||
if (field.type === "number") {
|
if (field.type === "number") {
|
||||||
|
if (!raw) return raw;
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isSafeInteger(value)) return 0;
|
if (!Number.isSafeInteger(value)) return 0;
|
||||||
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
||||||
@@ -60,7 +67,10 @@ function parseField(field: ContentCommandField, formData: FormData): unknown {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return raw.slice(0, field.maxLength ?? (field.type === "textarea" ? 20_000 : 500));
|
return raw.slice(
|
||||||
|
0,
|
||||||
|
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: HousekeepingResult<unknown> | null = null;
|
const initialState: HousekeepingResult<unknown> | null = null;
|
||||||
@@ -70,14 +80,13 @@ export async function submitContentCommandForm(
|
|||||||
_previous: HousekeepingResult<unknown> | null,
|
_previous: HousekeepingResult<unknown> | null,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<HousekeepingResult<unknown>> {
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
|
const submittedFields = (configuration.fields ?? []).flatMap((field) => {
|
||||||
|
const value = parseField(field, formData);
|
||||||
|
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
|
||||||
|
});
|
||||||
const input = {
|
const input = {
|
||||||
...configuration.input,
|
...configuration.input,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(submittedFields),
|
||||||
(configuration.fields ?? []).map((field) => [
|
|
||||||
field.name,
|
|
||||||
parseField(field, formData),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
const reason = String(formData.get("reason") ?? "")
|
const reason = String(formData.get("reason") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -127,7 +136,8 @@ export function ContentCommandForm({
|
|||||||
id={`${commandId}-${field.name}`}
|
id={`${commandId}-${field.name}`}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/> {field.label}
|
/>{" "}
|
||||||
|
{field.label}
|
||||||
</>
|
</>
|
||||||
) : field.type === "select" ? (
|
) : field.type === "select" ? (
|
||||||
<>
|
<>
|
||||||
@@ -139,6 +149,7 @@ export function ContentCommandForm({
|
|||||||
required={field.required}
|
required={field.required}
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
>
|
>
|
||||||
|
{field.required ? null : <option value="">No change</option>}
|
||||||
{field.options?.map((option) => (
|
{field.options?.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,16 @@ import {
|
|||||||
type ContentQueryInput,
|
type ContentQueryInput,
|
||||||
createContentQuery,
|
createContentQuery,
|
||||||
} from "./content-queries";
|
} from "./content-queries";
|
||||||
import { CONTENT_QUERY_DEFINITIONS } from "./content-queries-production";
|
import {
|
||||||
|
CONTENT_QUERY_DEFINITIONS,
|
||||||
|
loadProductionContentQuery,
|
||||||
|
} from "./content-queries-production";
|
||||||
|
|
||||||
|
const queryMocks = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
sql: { raw: (statement: string) => statement },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||||
|
|
||||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
const permissions = new Set(granted);
|
const permissions = new Set(granted);
|
||||||
@@ -35,6 +44,25 @@ const ready: ContentQueryData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("Content query", () => {
|
describe("Content query", () => {
|
||||||
|
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||||
|
const allRows = Array.from({ length: 600 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
title: `Article ${index + 1}`,
|
||||||
|
status: "published",
|
||||||
|
updated_at: null,
|
||||||
|
}));
|
||||||
|
queryMocks.execute.mockImplementationOnce(async (statement: string) => [
|
||||||
|
statement.includes("LIMIT 500") ? allRows.slice(0, 500) : allRows,
|
||||||
|
]);
|
||||||
|
const result = await loadProductionContentQuery({
|
||||||
|
routeId: "content.editorial.articles",
|
||||||
|
params: {},
|
||||||
|
list: { search: "", pageSize: 25, offset: 0 },
|
||||||
|
});
|
||||||
|
expect(result.total).toBe(600);
|
||||||
|
expect(result.items).toHaveLength(25);
|
||||||
|
});
|
||||||
|
|
||||||
it("never selects email template bodies into summary query results", () => {
|
it("never selects email template bodies into summary query results", () => {
|
||||||
const emailTemplates =
|
const emailTemplates =
|
||||||
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+126
-8
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
import { contentQuery } from "./queries/content-queries";
|
import { contentQuery } from "./queries/content-queries";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ async function total(
|
|||||||
context: HousekeepingCapabilityContext,
|
context: HousekeepingCapabilityContext,
|
||||||
routeId:
|
routeId:
|
||||||
| "content.editorial.articles"
|
| "content.editorial.articles"
|
||||||
|
| "content.media.banners"
|
||||||
| "content.media.photos"
|
| "content.media.photos"
|
||||||
| "content.media.library"
|
| "content.media.library"
|
||||||
| "content.localization.overview",
|
| "content.localization.overview",
|
||||||
@@ -17,7 +19,8 @@ async function total(
|
|||||||
routeId,
|
routeId,
|
||||||
list: { pageSize: 1, offset: 0 },
|
list: { pageSize: 1, offset: 0 },
|
||||||
});
|
});
|
||||||
return result.ok ? result.data.total : 0;
|
if (!result.ok) throw new Error("Content widget query unavailable");
|
||||||
|
return result.data.total;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadContentWidget(
|
export async function loadContentWidget(
|
||||||
@@ -31,11 +34,20 @@ export async function loadContentWidget(
|
|||||||
return { articles };
|
return { articles };
|
||||||
}
|
}
|
||||||
if (kind === "media") {
|
if (kind === "media") {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
if (context.has(PERMS.PAGES_VIEW)) {
|
||||||
const [photos, library] = await Promise.all([
|
const [photos, library] = await Promise.all([
|
||||||
total(context, "content.media.photos"),
|
total(context, "content.media.photos"),
|
||||||
total(context, "content.media.library"),
|
total(context, "content.media.library"),
|
||||||
]);
|
]);
|
||||||
return { photos, library, items: photos + library };
|
counts.photos = photos;
|
||||||
|
counts.library = library;
|
||||||
|
counts.items = photos + library;
|
||||||
|
}
|
||||||
|
if (context.has(PERMS.BANNERS_VIEW)) {
|
||||||
|
counts.banners = await total(context, "content.media.banners");
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
}
|
}
|
||||||
const stores = await total(context, "content.localization.overview");
|
const stores = await total(context, "content.localization.overview");
|
||||||
return { stores };
|
return { stores };
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe("admin operations route contract", () => {
|
|||||||
expect(source).not.toMatch(/await requireStaffRateLimited\(\)/);
|
expect(source).not.toMatch(/await requireStaffRateLimited\(\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has no requireStaff left in admin action modules", () => {
|
it("keeps requireStaff only for the approved legacy logo security floor", () => {
|
||||||
const dir = "src/actions";
|
const dir = "src/actions";
|
||||||
const offenders: string[] = [];
|
const offenders: string[] = [];
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
@@ -135,7 +135,7 @@ describe("admin operations route contract", () => {
|
|||||||
offenders.push(name);
|
offenders.push(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual(["save-logo.ts"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caches analytics full-scans via redisCache", () => {
|
it("caches analytics full-scans via redisCache", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user