fix(housekeeping): harden people account workflows

This commit is contained in:
Simo committed 2026-08-29 13:13:04 +02:00
1 parent e1b31ff773
commit 25b76437ff
41 files changed
+2657 -838

No files matched your search

@@ -43,8 +43,8 @@ function communityCommand<I>(
execute: (context, input) =>
service.execute(
{
capability: context.capability,
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
options.operation,
input,
@@ -165,12 +165,11 @@ describe("People user commands", () => {
describe("People mutation service boundary", () => {
it("fails closed before the adapter when the exact capability is absent", async () => {
const execute = vi.fn(async () => ({ before: null, after: null }));
const service = createPeopleMutationService({ execute });
const service = createPeopleMutationService({ execute }, async () =>
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await service.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "denied-people",
},
{ expectedActorId: 42, correlationId: "denied-people" },
"user.ban",
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
);
@@ -190,11 +189,11 @@ describe("People mutation service boundary", () => {
after: { rank: 3 },
}),
};
const success = await createPeopleMutationService(snapshotAdapter).execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "snapshot-people",
},
const success = await createPeopleMutationService(
snapshotAdapter,
async () => capabilityContext([PERMS.USERS_EDIT]),
).execute(
{ expectedActorId: 42, correlationId: "snapshot-people" },
"user.update",
{ userId: 7, fields: { rank: 3 } },
);
@@ -208,11 +207,11 @@ describe("People mutation service boundary", () => {
throw new Error("database password=secret");
},
};
const failure = await createPeopleMutationService(failureAdapter).execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "failed-people",
},
const failure = await createPeopleMutationService(
failureAdapter,
async () => capabilityContext([PERMS.USERS_EDIT]),
).execute(
{ expectedActorId: 42, correlationId: "failed-people" },
"user.update",
{ userId: 7, fields: { motto: "Ready" } },
);
@@ -53,8 +53,8 @@ function userCommand<I>(
execute: (context, input) =>
service.execute(
{
capability: context.capability,
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
options.operation,
input,
File diff suppressed because it is too large. Load diff
@@ -4,53 +4,60 @@ import type {
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state";
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
interface PeopleMultiAccountsPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleMultiAccountsPage({
context: _context,
result,
partialDependencies,
}: PeopleMultiAccountsPageProps) {
return (
<PeoplePageFrame
title="Multi-account clusters"
description="Review bounded account clusters grouped by current IP."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) =>
data.kind === "multi-accounts" && data.page.items.length === 0
}
>
{(data) =>
data.kind === "multi-accounts" ? (
<ul className="space-y-3">
{data.page.items.map((cluster) => (
<li
key={cluster.key}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
{cluster.key}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{cluster.accountCount} accounts
</p>
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
{cluster.accounts.map((account) => (
<li key={account.id}>
<a href={account.href}>{account.username}</a>
</li>
))}
</ul>
</li>
))}
</ul>
<div className="space-y-3">
<form method="get" className="flex gap-2">
<input
name="search"
maxLength={100}
aria-label="Search IP clusters"
/>
<button type="submit">Search</button>
</form>
<ul className="space-y-3">
{data.page.items.map((cluster) => (
<li
key={cluster.key}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
{cluster.key}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{cluster.accountCount} accounts
</p>
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
{cluster.accounts.map((account) => (
<li key={account.id}>
<a href={account.href}>{account.username}</a>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
) : null
}
</PeoplePageFrame>
@@ -62,7 +69,7 @@ export async function renderPeopleMultiAccountsPage(
) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.multi-accounts",
list: {},
list: parsePeopleListInput(input.searchParams ?? {}),
});
return <PeopleMultiAccountsPage context={input.context} result={result} />;
}
@@ -2,12 +2,60 @@ import type { ReactNode } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
import type { ListInput } from "../models";
export type PeopleSearchParams = Readonly<
Record<string, string | readonly string[] | undefined>
>;
function firstParam(
params: PeopleSearchParams,
key: string,
): string | undefined {
const value = params[key];
return typeof value === "string" ? value : value?.[0];
}
function boundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed)
? Math.min(maximum, Math.max(minimum, parsed))
: fallback;
}
export function parsePeopleListInput(params: PeopleSearchParams): ListInput {
const pageSize = boundedInteger(firstParam(params, "pageSize"), 20, 1, 100);
const page = boundedInteger(firstParam(params, "page"), 1, 1, 1_000_001);
const search = Array.from(firstParam(params, "search") ?? "")
.filter((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint >= 32 && codePoint !== 127;
})
.join("")
.normalize("NFC")
.trim()
.slice(0, 100);
return {
search,
pageSize,
offset: Math.min(100_000, (page - 1) * pageSize),
sort: (firstParam(params, "sort") ?? "")
.normalize("NFC")
.trim()
.slice(0, 32),
order: firstParam(params, "direction") === "desc" ? "desc" : "asc",
};
}
interface PeoplePageFrameProps<T> {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<T>;
readonly partialDependencies?: readonly string[];
readonly isEmpty: (data: T) => boolean;
readonly children: (data: T) => ReactNode;
}
@@ -32,7 +80,6 @@ export function PeoplePageFrame<T>({
title,
description,
result,
partialDependencies = [],
isEmpty,
children,
}: PeoplePageFrameProps<T>) {
@@ -76,20 +123,7 @@ export function PeoplePageFrame<T>({
);
} else {
body = (
<div
data-housekeeping-state={
partialDependencies.length > 0 ? "partial" : "ready"
}
className="space-y-4"
>
{partialDependencies.length > 0 ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial data"
title="Some People data is unavailable"
description={`Unavailable: ${partialDependencies.join(", ")}`}
/>
) : null}
<div data-housekeeping-state="ready" className="space-y-4">
{children(result.data)}
</div>
);
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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2,12 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({
audit: vi.fn(),
alertUser: vi.fn(),
selectLimit: vi.fn(),
}));
const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
audit: vi.fn(),
alertUser: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: vi.fn(),
}),
);
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => {
@@ -62,6 +68,7 @@ const target = {
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext([]));
selectLimit.mockResolvedValue([target]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
@@ -70,7 +77,7 @@ beforeEach(() => {
describe("People production mutation boundary", () => {
it("rechecks authorization before any production adapter work", async () => {
const result = await peopleMutationService.execute(
{ capability: capabilityContext([]), correlationId: "production-denied" },
{ expectedActorId: 42, correlationId: "production-denied" },
"user.alert",
{ userId: 7, message: "Hello" },
);
@@ -81,11 +88,11 @@ describe("People production mutation boundary", () => {
});
it("emits sanitized before and after evidence for a successful mutation", async () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-alert",
},
{ expectedActorId: 42, correlationId: "production-alert" },
"user.alert",
{ userId: 7, message: "Hello" },
);
@@ -107,17 +114,17 @@ describe("People production mutation boundary", () => {
);
});
it("fails closed when required audit evidence cannot be persisted", async () => {
it("blocks the external action when required intent evidence cannot be persisted", async () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
audit.mockRejectedValue(new Error("audit database unavailable"));
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-audit-failure",
},
{ expectedActorId: 42, correlationId: "production-audit-failure" },
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(alertUser).toHaveBeenCalled();
expect(alertUser).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { alertUser, audit, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
alertUser: vi.fn(),
audit: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: vi.fn(),
}),
);
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return {
...actual,
db: {
...actual.db,
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: selectLimit })),
})),
})),
},
};
});
vi.mock("@/lib/services/audit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/audit")>()),
logAudit: audit,
}));
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
rcon: { alertUser },
}));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
import { peopleMutationService } from "./mutations";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 6 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(context([]));
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
rank: 2,
motto: "Ready",
credits: 100,
pixels: 50,
online: "1",
},
]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
});
describe("People production server authority", () => {
it("rejects a caller-forged capability and resolves the authenticated request actor", async () => {
const result = await peopleMutationService.execute(
{
correlationId: "server-authority",
expectedActorId: 42,
capability: context([PERMS.USERS_EDIT]),
} as never,
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(resolveServerContext).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
correlationId: "server-authority",
});
expect(selectLimit).not.toHaveBeenCalled();
expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff