fix(housekeeping): harden people read boundaries
This commit is contained in:
1 parent
e3f8b51d31
commit
d1382c839e
12 files changed
+1635
-446
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { PEOPLE_ROUTES } from "../routes";
|
||||
import { createPeopleCommunityQuery } from "./community";
|
||||
import { createPeopleModerationQuery } from "./moderation";
|
||||
import { createPeopleStaffQuery } from "./staff";
|
||||
@@ -29,9 +30,75 @@ const rawUser = (id: number, username: string) => ({
|
||||
});
|
||||
|
||||
describe("People users query", () => {
|
||||
it("normalizes list input, sorts stable ties, and projects PII for admin users view", async () => {
|
||||
it("includes watched state and permission-rank data required by detail and edit", async () => {
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers: async () => ({ rows: [], total: 0 }),
|
||||
loadUser: async () => ({
|
||||
...rawUser(9, "Nine"),
|
||||
motto: "",
|
||||
look: "hd-180-1",
|
||||
accountCreated: 1_700_000_000,
|
||||
lastLogin: 1_700_000_100,
|
||||
}),
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set([9]),
|
||||
loadPermissionRanks: async () => [
|
||||
{ id: 1, name: "User" },
|
||||
{ id: 5, name: "Moderator" },
|
||||
],
|
||||
} as never);
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.detail",
|
||||
id: 9,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
user: {
|
||||
watched: true,
|
||||
permission: {
|
||||
rankName: "Moderator",
|
||||
ranks: [
|
||||
{ id: 1, name: "User" },
|
||||
{ id: 5, name: "Moderator" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a DB-paged username window without locale re-sort or offset slicing", async () => {
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers: async () => ({
|
||||
rows: [rawUser(10, "user10"), rawUser(2, "user2")],
|
||||
total: 20,
|
||||
}),
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.list",
|
||||
list: { sort: "username", order: "asc", offset: 2, pageSize: 2 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
kind: "users",
|
||||
page: { items: [{ id: 10 }, { id: 2 }], offset: 2, pageSize: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes list input and projects PII for admin users view", async () => {
|
||||
const loadUsers = vi.fn(async () => ({
|
||||
rows: [rawUser(3, "alice"), rawUser(1, "Alice"), rawUser(2, "alice")],
|
||||
rows: [rawUser(1, "Alice"), rawUser(2, "alice"), rawUser(3, "alice")],
|
||||
total: 3,
|
||||
}));
|
||||
const query = createPeopleUsersQuery({
|
||||
@@ -39,6 +106,8 @@ describe("People users query", () => {
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
@@ -94,6 +163,8 @@ describe("People users query", () => {
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), {
|
||||
@@ -132,6 +203,8 @@ describe("People users query", () => {
|
||||
loadUser,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const invalid = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
@@ -184,6 +257,8 @@ describe("People users query", () => {
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
@@ -208,6 +283,35 @@ describe("People users query", () => {
|
||||
});
|
||||
|
||||
describe("People community and staff queries", () => {
|
||||
it("fails closed when a community adapter returns a corrupt entity id", async () => {
|
||||
const query = createPeopleCommunityQuery({
|
||||
loadOnline: async () => ({ rows: [], total: 0 }),
|
||||
loadGuilds: async () => ({ rows: [], total: 0 }),
|
||||
loadGuild: async () =>
|
||||
({
|
||||
id: 0,
|
||||
name: "Corrupt",
|
||||
description: "",
|
||||
ownerId: -1,
|
||||
ownerUsername: null,
|
||||
memberCount: 0,
|
||||
createdAt: null,
|
||||
href: "/ase/people/community/guilds/0",
|
||||
roomId: 0,
|
||||
threadCount: 0,
|
||||
members: [],
|
||||
}) as never,
|
||||
});
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.community.guild-detail",
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads online, guild list, and guild detail workflows through narrow adapters", async () => {
|
||||
const loadOnline = vi.fn(async () => ({ rows: [], total: 0 }));
|
||||
const loadGuilds = vi.fn(async () => ({
|
||||
@@ -317,6 +421,161 @@ describe("People community and staff queries", () => {
|
||||
});
|
||||
|
||||
describe("People support query", () => {
|
||||
it("hydrates desk/detail support context and the help-ticket active ban", async () => {
|
||||
const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 };
|
||||
const staff = [
|
||||
{
|
||||
id: 8,
|
||||
username: "Helper",
|
||||
rank: 5,
|
||||
href: "/ase/people/users/8" as const,
|
||||
},
|
||||
];
|
||||
const activeBan = {
|
||||
id: 31,
|
||||
userId: 7,
|
||||
username: "Seven",
|
||||
staffId: 8,
|
||||
staffUsername: "Helper",
|
||||
type: "account",
|
||||
reason: "permanent",
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
expiresAt: 0 as const,
|
||||
active: true,
|
||||
};
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => queue,
|
||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadTicket: async () => ({
|
||||
id: 11,
|
||||
subject: "CMS ticket",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
creatorId: 7,
|
||||
creatorUsername: "Seven",
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
href: "/ase/people/support/tickets/11",
|
||||
category: "general",
|
||||
assigneeId: null,
|
||||
messages: [],
|
||||
}),
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => ({
|
||||
id: 12,
|
||||
title: "Help ticket",
|
||||
open: true,
|
||||
userId: 7,
|
||||
username: "Seven",
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
href: "/ase/people/support/help-tickets/12",
|
||||
categoryId: 2,
|
||||
categoryName: "Help",
|
||||
content: "Body",
|
||||
replies: [],
|
||||
}),
|
||||
loadSupportStaff: async () => staff,
|
||||
loadActiveBan: async () => activeBan,
|
||||
} as never);
|
||||
|
||||
const desk = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-desk",
|
||||
list: {},
|
||||
});
|
||||
const detail = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-detail",
|
||||
id: 11,
|
||||
});
|
||||
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.help-ticket-detail",
|
||||
id: 12,
|
||||
});
|
||||
expect(desk).toMatchObject({
|
||||
ok: true,
|
||||
data: { kind: "ticket-desk", queue, staff },
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
ok: true,
|
||||
data: { ticket: { queue, staff } },
|
||||
});
|
||||
expect(help).toMatchObject({
|
||||
ok: true,
|
||||
data: { ticket: { queue, staff, activeBan } },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when support returns a corrupt detail shape", async () => {
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
}),
|
||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadTicket: async () =>
|
||||
({ id: 0, href: "/ase/people/support/tickets/0" }) as never,
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-detail",
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["status", [7, 3]],
|
||||
["updatedAt", [8, 4]],
|
||||
] as const)(
|
||||
"preserves the DB-paged ticket window for %s ordering",
|
||||
async (sort, ids) => {
|
||||
const rows = ids.map((id, index) => ({
|
||||
source: "cms" as const,
|
||||
id,
|
||||
subject: `Ticket ${id}`,
|
||||
status: index === 0 ? "open" : "pending",
|
||||
priority: "normal",
|
||||
creatorId: 1,
|
||||
creatorUsername: "Reporter",
|
||||
updatedAt: `2026-08-${20 - index}T00:00:00.000Z`,
|
||||
href: `/ase/people/support/tickets/${id}` as const,
|
||||
}));
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
}),
|
||||
loadTickets: async () => ({ rows, total: 40 }),
|
||||
loadTicket: async () => null,
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-desk",
|
||||
list: { sort, order: "desc", offset: 10, pageSize: 2 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { page: { items: ids.map((id) => ({ id })), offset: 10 } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("returns the four-source queue snapshot and fails closed when a count fails", async () => {
|
||||
const loadQueue = vi
|
||||
.fn()
|
||||
@@ -334,6 +593,8 @@ describe("People support query", () => {
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -370,6 +631,8 @@ describe("People support query", () => {
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
|
||||
for (const routeId of [
|
||||
@@ -402,6 +665,59 @@ describe("People support query", () => {
|
||||
});
|
||||
|
||||
describe("People moderation query", () => {
|
||||
it("declares exactly the same eleven-permission union as the moderation overview route", () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
staffOnline: 0,
|
||||
recentActions: 0,
|
||||
}),
|
||||
loadCfh: async () => ({ rows: [], total: 0 }),
|
||||
loadCfhDetail: async () => null,
|
||||
loadBans: async () => ({ rows: [], total: 0 }),
|
||||
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
||||
loadVpnSettings: async () => [],
|
||||
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
||||
});
|
||||
const route = PEOPLE_ROUTES.find(
|
||||
(candidate) => candidate.id === "people.moderation.overview",
|
||||
);
|
||||
expect(route).toBeDefined();
|
||||
expect(query.capability).toEqual(route?.capability);
|
||||
expect(query.capability.slugs).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("fails closed when moderation returns a corrupt detail shape", async () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
staffOnline: 0,
|
||||
recentActions: 0,
|
||||
}),
|
||||
loadCfh: async () => ({ rows: [], total: 0 }),
|
||||
loadCfhDetail: async () =>
|
||||
({ id: -1, href: "/ase/people/moderation/cfh/-1" }) as never,
|
||||
loadBans: async () => ({ rows: [], total: 0 }),
|
||||
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
||||
loadVpnSettings: async () => [],
|
||||
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
||||
});
|
||||
const result = await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
||||
routeId: "people.moderation.cfh-detail",
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
|
||||
Reference in new issue
Block a user