fix(housekeeping): address people moderation review
This commit is contained in:
1 parent
29fe22297b
commit
3fa1119f5a
20 files changed
+2000
-777
No files matched your search
@@ -10,10 +10,21 @@ import { createCorrelationId } from "@/features/housekeeping/foundation/contract
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { canonicalTicketId } from "@/lib/services/ticket-replies";
|
||||||
|
|
||||||
const ticketIdField = z
|
const ticketIdField = z
|
||||||
.union([z.string(), z.number(), z.bigint()])
|
.union([z.string(), z.number(), z.bigint()])
|
||||||
.transform((v) => BigInt(String(v)));
|
.transform((value, context) => {
|
||||||
|
try {
|
||||||
|
return canonicalTicketId(value);
|
||||||
|
} catch {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Invalid ticket identifier",
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const replyHelpCenterTicketSchema = z.object({
|
const replyHelpCenterTicketSchema = z.object({
|
||||||
ticketId: ticketIdField,
|
ticketId: ticketIdField,
|
||||||
|
|||||||
+29
-30
File diff suppressed because it is too large.
Load diff
@@ -8,11 +8,34 @@ const { execute, registrations, staff } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function wrapper(
|
function wrapper(
|
||||||
options: { permission: string | readonly string[] },
|
options: {
|
||||||
|
permission: string | readonly string[];
|
||||||
|
schema?: {
|
||||||
|
safeParse(value: unknown):
|
||||||
|
| { success: true; data: unknown }
|
||||||
|
| { success: false; error: unknown };
|
||||||
|
};
|
||||||
|
},
|
||||||
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
|
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
|
||||||
) {
|
) {
|
||||||
registrations.push(options);
|
registrations.push(options);
|
||||||
return (data: unknown) => handler({ data, session: { user: staff } });
|
return async (data: unknown) => {
|
||||||
|
const parsed = options.schema?.safeParse(data);
|
||||||
|
if (parsed && !parsed.success) {
|
||||||
|
return { ok: false, error: "Validation failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handler({
|
||||||
|
data: parsed?.data ?? data,
|
||||||
|
session: { user: staff },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : "Internal server error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
@@ -202,6 +225,64 @@ describe("legacy People support and moderation wrappers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["kick", quickKick, { userId: 7 }],
|
||||||
|
["mute", quickMute, { userId: 7, duration: 60 }],
|
||||||
|
["unmute", quickUnmute, { userId: 7 }],
|
||||||
|
["alert", quickAlert, { userId: 7, message: "Stop" }],
|
||||||
|
["room kick", quickRoomKick, { roomId: 12 }],
|
||||||
|
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
|
||||||
|
] as const)(
|
||||||
|
"maps a non-ok %s service result to the historical legacy failure boundary",
|
||||||
|
async (_label, action, input) => {
|
||||||
|
execute.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "DEPENDENCY_UNAVAILABLE",
|
||||||
|
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||||
|
},
|
||||||
|
correlationId: "quick-action-failure",
|
||||||
|
});
|
||||||
|
await expect(call(action, input)).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Could not execute moderation action",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("maps a thrown moderation service failure instead of reporting success", async () => {
|
||||||
|
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
|
||||||
|
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "RCON unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
9_007_199_254_740_992,
|
||||||
|
"01",
|
||||||
|
"0",
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
"18446744073709551616",
|
||||||
|
] as const)(
|
||||||
|
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
|
||||||
|
async (ticketId) => {
|
||||||
|
for (const [action, input] of [
|
||||||
|
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
|
||||||
|
[closeHelpCenterTicket, { ticketId }],
|
||||||
|
[reopenHelpCenterTicket, { ticketId }],
|
||||||
|
[liftBanFromHelpTicket, { ticketId }],
|
||||||
|
] as const) {
|
||||||
|
await expect(call(action, input)).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(execute).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
||||||
execute.mockResolvedValueOnce({
|
execute.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { CANONICAL_TICKET_ID_PATTERN } from "@/lib/services/ticket-replies";
|
||||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||||
import { anyCapability } from "../../../foundation/contracts";
|
import { anyCapability } from "../../../foundation/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -24,26 +25,7 @@ export const SUPPORT_COMMAND_IDS = [
|
|||||||
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
|
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
|
||||||
const positiveId = z.number().int().positive();
|
const positiveId = z.number().int().positive();
|
||||||
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||||
const MAX_UNSIGNED_BIGINT = "18446744073709551615";
|
const bigintId = z.string().regex(CANONICAL_TICKET_ID_PATTERN);
|
||||||
function boundedDecimalPattern(maximum: string): RegExp {
|
|
||||||
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
|
||||||
for (let index = 0; index < maximum.length; index += 1) {
|
|
||||||
const maximumDigit = Number(maximum[index]);
|
|
||||||
const minimumDigit = index === 0 ? 1 : 0;
|
|
||||||
const upperDigit = maximumDigit - 1;
|
|
||||||
if (upperDigit < minimumDigit) continue;
|
|
||||||
const digit =
|
|
||||||
upperDigit === minimumDigit
|
|
||||||
? String(minimumDigit)
|
|
||||||
: `[${minimumDigit}-${upperDigit}]`;
|
|
||||||
alternatives.push(
|
|
||||||
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
alternatives.push(maximum);
|
|
||||||
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
|
||||||
}
|
|
||||||
const bigintId = z.string().regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
|
|
||||||
|
|
||||||
function command<I>(
|
function command<I>(
|
||||||
service: Pick<PeopleMutationService, "execute">,
|
service: Pick<PeopleMutationService, "execute">,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type HousekeepingWorkItem,
|
type HousekeepingWorkItem,
|
||||||
ok,
|
ok,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||||
import { peopleModerationQuery } from "./queries/moderation";
|
import { peopleModerationQuery } from "./queries/moderation";
|
||||||
import { peopleSupportQuery } from "./queries/support";
|
import { peopleSupportQuery } from "./queries/support";
|
||||||
|
|
||||||
@@ -41,17 +42,6 @@ export interface PeopleInboxAdapters {
|
|||||||
): Promise<readonly HousekeepingWorkItem[]>;
|
): Promise<readonly HousekeepingWorkItem[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeHref(href: string): boolean {
|
|
||||||
return (
|
|
||||||
href.startsWith("/ase/") &&
|
|
||||||
!href.includes("\\") &&
|
|
||||||
!Array.from(href).some((character) => {
|
|
||||||
const code = character.codePointAt(0) ?? 0;
|
|
||||||
return code < 32 || code === 127;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSource(
|
function createSource(
|
||||||
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
|
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
|
||||||
capability: CapabilityRequirement,
|
capability: CapabilityRequirement,
|
||||||
@@ -72,7 +62,7 @@ function createSource(
|
|||||||
items: items
|
items: items
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
safeHref(item.href) &&
|
isSafeHousekeepingHref(item.href) &&
|
||||||
satisfiesCapability(context, item.capability),
|
satisfiesCapability(context, item.capability),
|
||||||
)
|
)
|
||||||
.slice(0, 25),
|
.slice(0, 25),
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,10 @@ import {
|
|||||||
peopleSupportQuery,
|
peopleSupportQuery,
|
||||||
} from "../queries/support";
|
} from "../queries/support";
|
||||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||||
import { PeopleCommandForm } from "./people-command-form";
|
import {
|
||||||
|
PeopleCommandForm,
|
||||||
|
type PeopleCommandSubmission,
|
||||||
|
} from "./people-command-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
@@ -34,6 +37,52 @@ function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: numb
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PeopleTicketTemplate = Extract<
|
||||||
|
PeopleSupportQueryData,
|
||||||
|
{ readonly kind: "ticket-templates" }
|
||||||
|
>["page"]["items"][number];
|
||||||
|
|
||||||
|
export function ticketTemplateUpdateSubmission(
|
||||||
|
template: PeopleTicketTemplate,
|
||||||
|
): PeopleCommandSubmission {
|
||||||
|
return {
|
||||||
|
commandId: "people.ticket-template.change",
|
||||||
|
input: { action: "update", id: template.id },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
label: "Title",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxLength: 255,
|
||||||
|
defaultValue: template.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
label: "Content",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxLength: 5_000,
|
||||||
|
defaultValue: template.content,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "category",
|
||||||
|
label: "Category",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 50,
|
||||||
|
defaultValue: template.category,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sortOrder",
|
||||||
|
label: "Sort order",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: template.sortOrder,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function PeopleSupportPage({ context, result }: Props) {
|
export function PeopleSupportPage({ context, result }: Props) {
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
@@ -66,11 +115,17 @@ export function PeopleSupportPage({ context, result }: Props) {
|
|||||||
<h2>{template.title}</h2>
|
<h2>{template.title}</h2>
|
||||||
<p>{template.content}</p>
|
<p>{template.content}</p>
|
||||||
{context.has(PERMS.TICKETS_EDIT) ? (
|
{context.has(PERMS.TICKETS_EDIT) ? (
|
||||||
<PeopleCommandForm
|
<div className="space-y-3">
|
||||||
commandId="people.ticket-template.change"
|
<PeopleCommandForm
|
||||||
buttonLabel="Delete template"
|
{...ticketTemplateUpdateSubmission(template)}
|
||||||
input={{ action: "delete", id: template.id }}
|
buttonLabel="Update template"
|
||||||
/>
|
/>
|
||||||
|
<PeopleCommandForm
|
||||||
|
commandId="people.ticket-template.change"
|
||||||
|
buttonLabel="Delete template"
|
||||||
|
input={{ action: "delete", id: template.id }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execute: vi.fn(),
|
||||||
|
fetchTicketQueueOpenCounts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/ticket-queue-counts", () => ({
|
||||||
|
fetchTicketQueueOpenCounts: mocks.fetchTicketQueueOpenCounts,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => {
|
||||||
|
return { db: { execute: mocks.execute } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PEOPLE_WIDGETS } from "./widgets";
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 4 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(granted: readonly string[]) {
|
||||||
|
return PEOPLE_WIDGETS[0].load(
|
||||||
|
context(granted),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
|
||||||
|
cmsOpen: 1,
|
||||||
|
helpOpen: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("People production queue widget", () => {
|
||||||
|
it("wires ticket-only, CFH-only, ban-only, mixed, and denied contexts without unauthorized reads", async () => {
|
||||||
|
await expect(load([PERMS.MOD_TICKETS_VIEW])).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.execute).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.execute.mockResolvedValueOnce([[{ total: 3 }], []]);
|
||||||
|
await expect(load([PERMS.MOD_CFH_VIEW])).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 0, helpTickets: 0, cfh: 3, activeBans: 0 },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.execute).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.execute.mockResolvedValueOnce([[{ total: 4 }], []]);
|
||||||
|
await expect(load([PERMS.MOD_BANS_VIEW])).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 0, helpTickets: 0, cfh: 0, activeBans: 4 },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.execute).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
|
||||||
|
cmsOpen: 1,
|
||||||
|
helpOpen: 2,
|
||||||
|
});
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce([[{ total: 3 }], []])
|
||||||
|
.mockResolvedValueOnce([[{ total: 4 }], []]);
|
||||||
|
const mixed = await load([
|
||||||
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
|
PERMS.MOD_CFH_VIEW,
|
||||||
|
PERMS.MOD_BANS_VIEW,
|
||||||
|
]);
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.execute).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mixed).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await expect(load([])).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type HousekeepingSearchProvider,
|
type HousekeepingSearchProvider,
|
||||||
ok,
|
ok,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||||
import { peopleCommunityQuery } from "./queries/community";
|
import { peopleCommunityQuery } from "./queries/community";
|
||||||
import { peopleSupportQuery } from "./queries/support";
|
import { peopleSupportQuery } from "./queries/support";
|
||||||
import { peopleUsersQuery } from "./queries/users";
|
import { peopleUsersQuery } from "./queries/users";
|
||||||
@@ -47,17 +48,6 @@ export interface PeopleSearchAdapters {
|
|||||||
): Promise<readonly PeopleSearchCandidate[]>;
|
): Promise<readonly PeopleSearchCandidate[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeHref(href: string): href is `/ase/${string}` {
|
|
||||||
return (
|
|
||||||
href.startsWith("/ase/") &&
|
|
||||||
!href.includes("\\") &&
|
|
||||||
!Array.from(href).some((character) => {
|
|
||||||
const code = character.codePointAt(0) ?? 0;
|
|
||||||
return code < 32 || code === 127;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundedLimit(limit: number): number {
|
function boundedLimit(limit: number): number {
|
||||||
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
|
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
|
||||||
}
|
}
|
||||||
@@ -82,7 +72,7 @@ function createProvider(
|
|||||||
candidates
|
candidates
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
safeHref(item.href) &&
|
isSafeHousekeepingHref(item.href) &&
|
||||||
satisfiesCapability(context, item.capability),
|
satisfiesCapability(context, item.capability),
|
||||||
)
|
)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+291
-1
File diff suppressed because it is too large.
Load diff
@@ -93,4 +93,24 @@ describe("People production server authority", () => {
|
|||||||
expect(alertUser).not.toHaveBeenCalled();
|
expect(alertUser).not.toHaveBeenCalled();
|
||||||
expect(audit).not.toHaveBeenCalled();
|
expect(audit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks Task 13 moderation work before intent audit or transport on actor mismatch", async () => {
|
||||||
|
resolveServerContext.mockResolvedValue(context([PERMS.MOD_ACTIONS]));
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{
|
||||||
|
correlationId: "task13-server-authority",
|
||||||
|
expectedActorId: 99,
|
||||||
|
legacy: true,
|
||||||
|
},
|
||||||
|
"moderation.action",
|
||||||
|
{ action: "alert", userId: 7, message: "Stop" },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
correlationId: "task13-server-authority",
|
||||||
|
});
|
||||||
|
expect(alertUser).not.toHaveBeenCalled();
|
||||||
|
expect(audit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { createPeopleQueueAggregateLoader } from "./widgets";
|
||||||
|
|
||||||
|
function count(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error("invalid queue count");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadPeopleQueueAggregate = createPeopleQueueAggregateLoader({
|
||||||
|
async support(signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const counts = await fetchTicketQueueOpenCounts({ strict: true });
|
||||||
|
return { tickets: counts.cmsOpen, helpTickets: counts.helpOpen };
|
||||||
|
},
|
||||||
|
async cfh(signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const result = await db.execute(
|
||||||
|
sql`SELECT COUNT(*) AS total FROM support_tickets WHERE state <> 2`,
|
||||||
|
);
|
||||||
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
|
throw new Error("invalid CFH queue count");
|
||||||
|
}
|
||||||
|
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
|
||||||
|
},
|
||||||
|
async activeBans(signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT COUNT(*) AS total FROM bans
|
||||||
|
WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()
|
||||||
|
`);
|
||||||
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
|
throw new Error("invalid ban queue count");
|
||||||
|
}
|
||||||
|
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
ok,
|
ok,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
import type { PeopleQueueSnapshot } from "./models";
|
import type { PeopleQueueSnapshot } from "./models";
|
||||||
import { peopleSupportQuery } from "./queries/support";
|
|
||||||
|
|
||||||
export interface PeopleWidgetAdapters {
|
export interface PeopleWidgetAdapters {
|
||||||
queue(
|
queue(
|
||||||
@@ -20,6 +19,14 @@ export interface PeopleWidgetAdapters {
|
|||||||
): Promise<PeopleQueueSnapshot>;
|
): Promise<PeopleQueueSnapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PeopleQueueAggregateAdapters {
|
||||||
|
support(
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Readonly<{ tickets: number; helpTickets: number }>>;
|
||||||
|
cfh(signal: AbortSignal): Promise<number>;
|
||||||
|
activeBans(signal: AbortSignal): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
const supportQueueCapability = anyCapability(
|
const supportQueueCapability = anyCapability(
|
||||||
PERMS.TICKETS_VIEW,
|
PERMS.TICKETS_VIEW,
|
||||||
PERMS.MOD_TICKETS_VIEW,
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
@@ -38,6 +45,40 @@ const queueCapability = anyCapability(
|
|||||||
...banQueueCapability.slugs,
|
...banQueueCapability.slugs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function count(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error("invalid queue count");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPeopleQueueAggregateLoader(
|
||||||
|
adapters: PeopleQueueAggregateAdapters,
|
||||||
|
): PeopleWidgetAdapters["queue"] {
|
||||||
|
return async (context, signal) => {
|
||||||
|
const authorization = authorizeHousekeeping(context, queueCapability);
|
||||||
|
if (!authorization.ok) throw new Error("forbidden queue aggregate");
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const supportAllowed = satisfiesCapability(context, supportQueueCapability);
|
||||||
|
const cfhAllowed = satisfiesCapability(context, cfhQueueCapability);
|
||||||
|
const bansAllowed = satisfiesCapability(context, banQueueCapability);
|
||||||
|
const [support, cfh, activeBans] = await Promise.all([
|
||||||
|
supportAllowed
|
||||||
|
? adapters.support(signal)
|
||||||
|
: Promise.resolve({ tickets: 0, helpTickets: 0 }),
|
||||||
|
cfhAllowed ? adapters.cfh(signal) : Promise.resolve(0),
|
||||||
|
bansAllowed ? adapters.activeBans(signal) : Promise.resolve(0),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
tickets: count(support.tickets),
|
||||||
|
helpTickets: count(support.helpTickets),
|
||||||
|
cfh: count(cfh),
|
||||||
|
activeBans: count(activeBans),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createPeopleWidgets(
|
export function createPeopleWidgets(
|
||||||
adapters: PeopleWidgetAdapters,
|
adapters: PeopleWidgetAdapters,
|
||||||
): readonly HousekeepingWidgetDefinition[] {
|
): readonly HousekeepingWidgetDefinition[] {
|
||||||
@@ -80,11 +121,8 @@ export function createPeopleWidgets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PEOPLE_WIDGETS = createPeopleWidgets({
|
export const PEOPLE_WIDGETS = createPeopleWidgets({
|
||||||
async queue(context) {
|
async queue(context, signal) {
|
||||||
const result = await peopleSupportQuery.run(context, {
|
const { loadPeopleQueueAggregate } = await import("./widgets-production");
|
||||||
routeId: "people.support.queue",
|
return loadPeopleQueueAggregate(context, signal);
|
||||||
});
|
|
||||||
if (!result.ok || result.data.kind !== "queue") throw new Error("queue");
|
|
||||||
return result.data.queue;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -138,10 +138,20 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/lib/auth",
|
"src/lib/auth",
|
||||||
"src/lib/auth/password",
|
"src/lib/auth/password",
|
||||||
"src/lib/db",
|
"src/lib/db",
|
||||||
|
"src/features/housekeeping/domains/people/services/moderation-mutations",
|
||||||
|
"src/features/housekeeping/domains/people/services/support-mutations",
|
||||||
"drizzle-orm",
|
"drizzle-orm",
|
||||||
"mysql2",
|
"mysql2",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/people/services/moderation-mutations.ts",
|
||||||
|
new Set(["src/lib/db", "drizzle-orm"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/people/services/support-mutations.ts",
|
||||||
|
new Set(["src/lib/db", "drizzle-orm"]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/people/manifest.ts",
|
"src/features/housekeeping/domains/people/manifest.ts",
|
||||||
new Set([
|
new Set([
|
||||||
@@ -170,7 +180,15 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/people/widgets.ts",
|
"src/features/housekeeping/domains/people/widgets.ts",
|
||||||
new Set([
|
new Set([
|
||||||
"src/features/housekeeping/domains/people/models",
|
"src/features/housekeeping/domains/people/models",
|
||||||
"src/features/housekeeping/domains/people/queries/support",
|
"src/features/housekeeping/domains/people/widgets-production",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/people/widgets-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/widgets",
|
||||||
|
"src/lib/db",
|
||||||
|
"drizzle-orm",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const HOUSEKEEPING_ORIGIN = "https://housekeeping.invalid";
|
||||||
|
|
||||||
|
function containsControlCharacter(value: string): boolean {
|
||||||
|
return Array.from(value).some((character) => {
|
||||||
|
const code = character.codePointAt(0) ?? 0;
|
||||||
|
return code < 32 || code === 127;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodedVariants(value: string): readonly string[] | null {
|
||||||
|
const variants = [value];
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(variants.at(-1) ?? "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (decoded === variants.at(-1)) break;
|
||||||
|
variants.push(decoded);
|
||||||
|
}
|
||||||
|
return variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsDotSegment(path: string): boolean {
|
||||||
|
return path.split("/").some((segment) => segment === "." || segment === "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeHousekeepingHref(href: string): boolean {
|
||||||
|
if (
|
||||||
|
href.length === 0 ||
|
||||||
|
!href.startsWith("/") ||
|
||||||
|
href.startsWith("//") ||
|
||||||
|
containsControlCharacter(href) ||
|
||||||
|
href.includes("\\")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = decodedVariants(href);
|
||||||
|
if (
|
||||||
|
variants === null ||
|
||||||
|
variants.some(
|
||||||
|
(value) => containsControlCharacter(value) || value.includes("\\"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPath = href.split(/[?#]/u, 1)[0] ?? "";
|
||||||
|
const pathVariants = decodedVariants(rawPath);
|
||||||
|
if (
|
||||||
|
pathVariants === null ||
|
||||||
|
pathVariants.some((path) => containsDotSegment(path))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized: URL;
|
||||||
|
try {
|
||||||
|
normalized = new URL(href, HOUSEKEEPING_ORIGIN);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
normalized.origin === HOUSEKEEPING_ORIGIN &&
|
||||||
|
(normalized.pathname === "/ase" ||
|
||||||
|
normalized.pathname.startsWith("/ase/"))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,23 +19,51 @@ export interface TicketReplyDb {
|
|||||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
|
export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
|
||||||
|
|
||||||
|
function boundedDecimalPattern(maximum: string): RegExp {
|
||||||
|
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
||||||
|
for (let index = 0; index < maximum.length; index += 1) {
|
||||||
|
const maximumDigit = Number(maximum[index]);
|
||||||
|
const minimumDigit = index === 0 ? 1 : 0;
|
||||||
|
const upperDigit = maximumDigit - 1;
|
||||||
|
if (upperDigit < minimumDigit) continue;
|
||||||
|
const digit =
|
||||||
|
upperDigit === minimumDigit
|
||||||
|
? String(minimumDigit)
|
||||||
|
: `[${minimumDigit}-${upperDigit}]`;
|
||||||
|
alternatives.push(
|
||||||
|
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
alternatives.push(maximum);
|
||||||
|
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
|
||||||
|
MAX_UNSIGNED_TICKET_ID.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
||||||
export function canonicalTicketId(value: string | number | bigint): bigint {
|
export function canonicalTicketId(value: string | number | bigint): bigint {
|
||||||
let parsed: bigint;
|
let parsed: bigint;
|
||||||
try {
|
try {
|
||||||
if (
|
if (typeof value === "bigint") {
|
||||||
typeof value === "number" &&
|
parsed = value;
|
||||||
(!Number.isSafeInteger(value) || value <= 0)
|
} else if (typeof value === "string") {
|
||||||
) {
|
if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
|
||||||
|
throw new Error("noncanonical identifier");
|
||||||
|
}
|
||||||
|
parsed = BigInt(value);
|
||||||
|
} else if (Number.isSafeInteger(value) && value > 0) {
|
||||||
|
parsed = BigInt(value);
|
||||||
|
} else {
|
||||||
throw new Error("unsafe identifier");
|
throw new Error("unsafe identifier");
|
||||||
}
|
}
|
||||||
parsed = BigInt(String(value));
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("invalid ticket identifier");
|
throw new Error("invalid ticket identifier");
|
||||||
}
|
}
|
||||||
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
|
if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
|
||||||
throw new Error("invalid ticket identifier");
|
throw new Error("invalid ticket identifier");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|||||||
Reference in new issue
Block a user