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",
|
"id",
|
||||||
),
|
),
|
||||||
).toMatchObject({ pageSize: 20, offset: 0 });
|
).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(
|
const input = normalizePeopleListInput(
|
||||||
{ pageSize: 2, offset: 1, sort: "username", order: "asc" },
|
{ pageSize: 2, offset: 1, sort: "username", order: "asc" },
|
||||||
["id", "username"],
|
["id", "username"],
|
||||||
@@ -51,20 +54,17 @@ describe("People list normalization", () => {
|
|||||||
);
|
);
|
||||||
const page = createPeoplePage(
|
const page = createPeoplePage(
|
||||||
[
|
[
|
||||||
{ id: 4, username: "Bob" },
|
|
||||||
{ id: 3, username: "alice" },
|
{ id: 3, username: "alice" },
|
||||||
{ id: 1, username: "Alice" },
|
{ id: 1, username: "Alice" },
|
||||||
{ id: 2, username: "alice" },
|
|
||||||
],
|
],
|
||||||
4,
|
4,
|
||||||
input,
|
input,
|
||||||
(row) => row.username,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(page).toEqual({
|
expect(page).toEqual({
|
||||||
items: [
|
items: [
|
||||||
{ id: 2, username: "alice" },
|
|
||||||
{ id: 3, username: "alice" },
|
{ id: 3, username: "alice" },
|
||||||
|
{ id: 1, username: "Alice" },
|
||||||
],
|
],
|
||||||
total: 4,
|
total: 4,
|
||||||
pageSize: 2,
|
pageSize: 2,
|
||||||
@@ -116,6 +116,11 @@ describe("People canonical models", () => {
|
|||||||
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
|
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
|
||||||
lastLogin: toPeopleIsoDate(1_700_000_000),
|
lastLogin: toPeopleIsoDate(1_700_000_000),
|
||||||
sanctions: [],
|
sanctions: [],
|
||||||
|
watched: false,
|
||||||
|
permission: {
|
||||||
|
rankName: "Moderator",
|
||||||
|
ranks: [{ id: 5, name: "Moderator" }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(JSON.parse(JSON.stringify(detail))).toEqual({
|
expect(JSON.parse(JSON.stringify(detail))).toEqual({
|
||||||
@@ -132,6 +137,11 @@ describe("People canonical models", () => {
|
|||||||
accountCreated: "2026-08-29T10:20:30.000Z",
|
accountCreated: "2026-08-29T10:20:30.000Z",
|
||||||
lastLogin: "2023-11-14T22:13:20.000Z",
|
lastLogin: "2023-11-14T22:13:20.000Z",
|
||||||
sanctions: [],
|
sanctions: [],
|
||||||
|
watched: false,
|
||||||
|
permission: {
|
||||||
|
rankName: "Moderator",
|
||||||
|
ranks: [{ id: 5, name: "Moderator" }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,4 +160,16 @@ describe("People canonical models", () => {
|
|||||||
"/ase/people/moderation/cfh/8",
|
"/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,
|
ok,
|
||||||
} from "../../../foundation/contracts";
|
} from "../../../foundation/contracts";
|
||||||
import {
|
import {
|
||||||
|
assertPeopleSerializable,
|
||||||
createPeoplePage,
|
createPeoplePage,
|
||||||
type ListInput,
|
type ListInput,
|
||||||
normalizePeopleListInput,
|
normalizePeopleListInput,
|
||||||
@@ -77,9 +78,15 @@ export function createPeopleCommunityQuery(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const guild = await adapters.loadGuild(input.id);
|
const guild = await adapters.loadGuild(input.id);
|
||||||
return guild === null
|
if (guild === null) {
|
||||||
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId)
|
return fail(
|
||||||
: ok({ kind: "guild" as const, guild }, correlationId);
|
"NOT_FOUND",
|
||||||
|
"errors.housekeeping.notFound",
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertPeopleSerializable(guild);
|
||||||
|
return ok({ kind: "guild" as const, guild }, correlationId);
|
||||||
} catch {
|
} catch {
|
||||||
return unavailable(correlationId);
|
return unavailable(correlationId);
|
||||||
}
|
}
|
||||||
@@ -87,7 +94,9 @@ export function createPeopleCommunityQuery(
|
|||||||
|
|
||||||
const list = normalizePeopleListInput(
|
const list = normalizePeopleListInput(
|
||||||
input.list,
|
input.list,
|
||||||
["id", "username", "name"],
|
input.routeId === "people.community.online"
|
||||||
|
? ["id", "username"]
|
||||||
|
: ["id", "name"],
|
||||||
"id",
|
"id",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -96,9 +105,7 @@ export function createPeopleCommunityQuery(
|
|||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
kind: "online" as const,
|
kind: "online" as const,
|
||||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
page: createPeoplePage(result.rows, result.total, list),
|
||||||
list.sort === "username" ? row.username : row.id,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
@@ -108,9 +115,7 @@ export function createPeopleCommunityQuery(
|
|||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
kind: "guilds" as const,
|
kind: "guilds" as const,
|
||||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
page: createPeoplePage(result.rows, result.total, list),
|
||||||
list.sort === "name" ? row.name : row.id,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
@@ -122,11 +127,13 @@ export function createPeopleCommunityQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resultRows<T>(result: unknown): T[] {
|
function resultRows<T>(result: unknown): T[] {
|
||||||
if (!Array.isArray(result)) return [];
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
throw new Error("invalid People driver result");
|
||||||
|
}
|
||||||
|
return result[0] as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||||
async loadOnline(input) {
|
async loadOnline(input) {
|
||||||
const [{ sql }, { db }] = await Promise.all([
|
const [{ sql }, { db }] = await Promise.all([
|
||||||
import("drizzle-orm"),
|
import("drizzle-orm"),
|
||||||
@@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
|||||||
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
|
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
|
||||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||||
}, id 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}`),
|
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`} ${
|
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
|
||||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||||
}, g.id ASC
|
}, g.id ASC
|
||||||
LIMIT ${input.offset + input.pageSize}
|
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||||
`),
|
`),
|
||||||
db.execute(sql`
|
db.execute(sql`
|
||||||
SELECT COUNT(*) AS total
|
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);
|
const authorization = authorizeHousekeeping(context, requirement);
|
||||||
if (!authorization.ok) return authorization;
|
if (!authorization.ok) return authorization;
|
||||||
const correlationId = authorization.correlationId;
|
const correlationId = authorization.correlationId;
|
||||||
const list = normalizePeopleListInput(
|
const allowedSorts =
|
||||||
input.list,
|
input.routeId === "people.staff.applications"
|
||||||
["id", "name", "username", "createdAt"],
|
? ["id", "username", "createdAt"]
|
||||||
"id",
|
: input.routeId === "people.staff.teams"
|
||||||
);
|
? ["id", "name"]
|
||||||
|
: ["id", "username"];
|
||||||
|
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (input.routeId === "people.staff.applications") {
|
if (input.routeId === "people.staff.applications") {
|
||||||
@@ -87,13 +89,7 @@ export function createPeopleStaffQuery(
|
|||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
kind: "applications" as const,
|
kind: "applications" as const,
|
||||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
page: createPeoplePage(result.rows, result.total, list),
|
||||||
list.sort === "username"
|
|
||||||
? (row.username ?? "")
|
|
||||||
: list.sort === "createdAt"
|
|
||||||
? (row.createdAt ?? "")
|
|
||||||
: row.id,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
@@ -104,9 +100,7 @@ export function createPeopleStaffQuery(
|
|||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
kind: "teams" as const,
|
kind: "teams" as const,
|
||||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
page: createPeoplePage(result.rows, result.total, list),
|
||||||
list.sort === "name" ? row.name : row.id,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
@@ -116,9 +110,7 @@ export function createPeopleStaffQuery(
|
|||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
kind: "moderation-team" as const,
|
kind: "moderation-team" as const,
|
||||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
page: createPeoplePage(result.rows, result.total, list),
|
||||||
list.sort === "username" ? row.username : row.id,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
@@ -134,11 +126,13 @@ export function createPeopleStaffQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resultRows<T>(result: unknown): T[] {
|
function resultRows<T>(result: unknown): T[] {
|
||||||
if (!Array.isArray(result)) return [];
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
throw new Error("invalid People driver result");
|
||||||
|
}
|
||||||
|
return result[0] as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const peopleStaffAdapters: PeopleStaffAdapters = {
|
const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||||
async loadApplications(input) {
|
async loadApplications(input) {
|
||||||
const [{ sql }, { db }] = await Promise.all([
|
const [{ sql }, { db }] = await Promise.all([
|
||||||
import("drizzle-orm"),
|
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`} ${
|
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`
|
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||||
}, a.id ASC
|
}, a.id ASC
|
||||||
LIMIT ${input.offset + input.pageSize}
|
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||||
`),
|
`),
|
||||||
db.execute(sql`
|
db.execute(sql`
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
@@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
|||||||
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
|
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
|
||||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||||
}, id 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}`),
|
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`} ${
|
ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${
|
||||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||||
}, u.id ASC
|
}, u.id ASC
|
||||||
LIMIT ${input.offset + input.pageSize}
|
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||||
`),
|
`),
|
||||||
db.execute(sql`
|
db.execute(sql`
|
||||||
SELECT COUNT(*) AS total FROM users u
|
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";
|
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||||
|
|
||||||
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
|
/** 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;
|
cmsOpen: number;
|
||||||
helpOpen: number;
|
helpOpen: number;
|
||||||
}> {
|
}> {
|
||||||
const [cmsOpen, helpOpen] = await Promise.all([
|
const cms = db
|
||||||
db
|
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(WebsiteTicket)
|
.from(WebsiteTicket)
|
||||||
.where(ne(WebsiteTicket.status, "closed"))
|
.where(ne(WebsiteTicket.status, "closed"));
|
||||||
.then((rows) => rows[0]?.total ?? 0)
|
const help = db
|
||||||
.catch(() => 0),
|
|
||||||
db
|
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(WebsiteHelpCenterTickets)
|
.from(WebsiteHelpCenterTickets)
|
||||||
.where(eq(WebsiteHelpCenterTickets.open, true))
|
.where(eq(WebsiteHelpCenterTickets.open, true));
|
||||||
.then((rows) => rows[0]?.total ?? 0)
|
const readCount = async (
|
||||||
.catch(() => 0),
|
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 };
|
return { cmsOpen, helpOpen };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user