fix(housekeeping): harden people read boundaries
This commit is contained in:
1 parent
e3f8b51d31
commit
d1382c839e
12 files changed
+1610
-421
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -41,9 +41,12 @@ describe("People list normalization", () => {
|
||||
"id",
|
||||
),
|
||||
).toMatchObject({ pageSize: 20, offset: 0 });
|
||||
expect(
|
||||
normalizePeopleListInput({ offset: 999_999 }, ["id"], "id").offset,
|
||||
).toBe(10_000);
|
||||
});
|
||||
|
||||
it("sorts a page deterministically with an id tie breaker", () => {
|
||||
it("wraps rows already sorted and paged by the adapter", () => {
|
||||
const input = normalizePeopleListInput(
|
||||
{ pageSize: 2, offset: 1, sort: "username", order: "asc" },
|
||||
["id", "username"],
|
||||
@@ -51,20 +54,17 @@ describe("People list normalization", () => {
|
||||
);
|
||||
const page = createPeoplePage(
|
||||
[
|
||||
{ id: 4, username: "Bob" },
|
||||
{ id: 3, username: "alice" },
|
||||
{ id: 1, username: "Alice" },
|
||||
{ id: 2, username: "alice" },
|
||||
],
|
||||
4,
|
||||
input,
|
||||
(row) => row.username,
|
||||
);
|
||||
|
||||
expect(page).toEqual({
|
||||
items: [
|
||||
{ id: 2, username: "alice" },
|
||||
{ id: 3, username: "alice" },
|
||||
{ id: 1, username: "Alice" },
|
||||
],
|
||||
total: 4,
|
||||
pageSize: 2,
|
||||
@@ -116,6 +116,11 @@ describe("People canonical models", () => {
|
||||
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
|
||||
lastLogin: toPeopleIsoDate(1_700_000_000),
|
||||
sanctions: [],
|
||||
watched: false,
|
||||
permission: {
|
||||
rankName: "Moderator",
|
||||
ranks: [{ id: 5, name: "Moderator" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(JSON.parse(JSON.stringify(detail))).toEqual({
|
||||
@@ -132,6 +137,11 @@ describe("People canonical models", () => {
|
||||
accountCreated: "2026-08-29T10:20:30.000Z",
|
||||
lastLogin: "2023-11-14T22:13:20.000Z",
|
||||
sanctions: [],
|
||||
watched: false,
|
||||
permission: {
|
||||
rankName: "Moderator",
|
||||
ranks: [{ id: 5, name: "Moderator" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,4 +160,16 @@ describe("People canonical models", () => {
|
||||
"/ase/people/moderation/cfh/8",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects corrupt identifiers and non-serializable date values", () => {
|
||||
expect(() =>
|
||||
normalizePeopleUser(
|
||||
{ ...rawUser, id: 0 },
|
||||
{ includeMail: false, includeIp: false },
|
||||
),
|
||||
).toThrow();
|
||||
expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow();
|
||||
expect(() => peopleGuildHref(-4)).toThrow();
|
||||
expect(() => toPeopleIsoDate("not-a-date")).toThrow();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
assertPeopleSerializable,
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
@@ -77,9 +78,15 @@ export function createPeopleCommunityQuery(
|
||||
}
|
||||
try {
|
||||
const guild = await adapters.loadGuild(input.id);
|
||||
return guild === null
|
||||
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId)
|
||||
: ok({ kind: "guild" as const, guild }, correlationId);
|
||||
if (guild === null) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
assertPeopleSerializable(guild);
|
||||
return ok({ kind: "guild" as const, guild }, correlationId);
|
||||
} catch {
|
||||
return unavailable(correlationId);
|
||||
}
|
||||
@@ -87,7 +94,9 @@ export function createPeopleCommunityQuery(
|
||||
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "username", "name"],
|
||||
input.routeId === "people.community.online"
|
||||
? ["id", "username"]
|
||||
: ["id", "name"],
|
||||
"id",
|
||||
);
|
||||
try {
|
||||
@@ -96,9 +105,7 @@ export function createPeopleCommunityQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "online" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username" ? row.username : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -108,9 +115,7 @@ export function createPeopleCommunityQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "guilds" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "name" ? row.name : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -122,11 +127,13 @@ export function createPeopleCommunityQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
async loadOnline(input) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
@@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`),
|
||||
]);
|
||||
@@ -187,7 +194,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, g.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+311
-122
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -75,11 +75,13 @@ export function createPeopleStaffQuery(
|
||||
const authorization = authorizeHousekeeping(context, requirement);
|
||||
if (!authorization.ok) return authorization;
|
||||
const correlationId = authorization.correlationId;
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "name", "username", "createdAt"],
|
||||
"id",
|
||||
);
|
||||
const allowedSorts =
|
||||
input.routeId === "people.staff.applications"
|
||||
? ["id", "username", "createdAt"]
|
||||
: input.routeId === "people.staff.teams"
|
||||
? ["id", "name"]
|
||||
: ["id", "username"];
|
||||
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
|
||||
|
||||
try {
|
||||
if (input.routeId === "people.staff.applications") {
|
||||
@@ -87,13 +89,7 @@ export function createPeopleStaffQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "applications" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username"
|
||||
? (row.username ?? "")
|
||||
: list.sort === "createdAt"
|
||||
? (row.createdAt ?? "")
|
||||
: row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -104,9 +100,7 @@ export function createPeopleStaffQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "teams" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "name" ? row.name : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -116,9 +110,7 @@ export function createPeopleStaffQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "moderation-team" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username" ? row.username : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -134,11 +126,13 @@ export function createPeopleStaffQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
async loadApplications(input) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
@@ -158,7 +152,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`a.created_at` : sql`a.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, a.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total
|
||||
@@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`),
|
||||
]);
|
||||
@@ -248,7 +242,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, u.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM users u
|
||||
|
||||
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
@@ -4,23 +4,33 @@ import { count, eq, ne } from "drizzle-orm";
|
||||
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||
|
||||
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
|
||||
export async function fetchTicketQueueOpenCounts(): Promise<{
|
||||
export async function fetchTicketQueueOpenCounts(options?: {
|
||||
readonly strict?: boolean;
|
||||
}): Promise<{
|
||||
cmsOpen: number;
|
||||
helpOpen: number;
|
||||
}> {
|
||||
const [cmsOpen, helpOpen] = await Promise.all([
|
||||
db
|
||||
const cms = db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.where(ne(WebsiteTicket.status, "closed"));
|
||||
const help = db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true));
|
||||
const readCount = async (
|
||||
query: typeof cms | typeof help,
|
||||
): Promise<number> => {
|
||||
const rows = await query;
|
||||
const value = Number(rows[0]?.total);
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("invalid ticket queue count");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const [cmsOpen, helpOpen] = await Promise.all([
|
||||
options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
|
||||
options?.strict ? readCount(help) : readCount(help).catch(() => 0),
|
||||
]);
|
||||
return { cmsOpen, helpOpen };
|
||||
}
|
||||
Reference in new issue
Block a user