fix(housekeeping): complete people workflow fidelity

This commit is contained in:
Simo committed 2026-08-29 14:39:38 +02:00
1 parent 25b76437ff
commit 91c9efcbb4
29 files changed
+1341 -266

No files matched your search

@@ -206,3 +206,52 @@ Exit 0
``` ```
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed. The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
## Official review fix round 2
The round-1 re-review reported 0 Critical, 7 Important, and no Minor findings. This round addresses all seven findings without changing the nine-route Task 12 manifest or exposing any raw production adapter.
### RED evidence
```text
Typed partial/result contract: 5 failed / 8 passed before completion metadata and audit-outcome handling were added.
Observed active-ban and absent-settings snapshots: 2 focused failures before deterministic active reads and null-preserving trade snapshots.
BIGINT preservation: 8 focused failures across application, team, IP, and wordfilter before decimal string/BigInt boundaries.
Real form/reset workflow: 2 primary-page failures before the actual action adapter and one-time credential result were added.
Production operation closure: 2 failed / 10 passed before unban observed-after and bulk false-RCON partial truth.
Post-commit notification/legacy parity: 2 failed / 12 passed before update/reset transactional intent and legacy throw/false mapping.
Cumulative gate exposed one unsupported custom Zod schema, one stale direct-alert expectation, and one stale numeric audit-ID expectation; each received a minimal regression-preserving fix.
```
### GREEN implementation
- Mixed database/external operations now commit sanitized intent with the mutation and return correlated typed `partial` completion when RCON, cache, notification, or final audit persistence fails afterward. Pre-mutation external failure and intent persistence failure remain blocking. The dispatcher and public server action preserve one serializable partial result and emit no contradictory generic failure evidence.
- Ban and unban reuse the permanent-or-unexpired Task 11 filter, deterministic timestamp/ID ordering, and observed before/after reads. An absent `UsersSettings` row remains null before and after a no-op trade settings update.
- Legacy alert calls RCON without a target query or hierarchy guard. Legacy wrappers retain their prior false/throw behavior while new Housekeeping commands report synchronization false as partial truth.
- Application, team, IP, and wordfilter identifiers remain canonical decimal strings/`BigInt` through wrappers and Drizzle, including values above `Number.MAX_SAFE_INTEGER`. The cloneable command regex accepts the full unsigned BIGINT range and rejects overflow without a Zod custom refinement.
- Reset-password returns the generated credential once in the current authorized form result. It is rendered through an accessible `output`, excluded from durable service evidence, and recursively redacted by the canonical audit sanitizer.
- Production tests execute all twenty Task 12 operation IDs with meaningful database/RCON/audit assertions. The primary-page test invokes the actual form action adapter, and the unused multi-accounts-to-command-form boundary exception was removed.
### Final verification after review fix round 2
```text
Focused People + foundation + wrappers + audit + action + staff-smoke matrix
Test Files 45 passed (45)
Tests 459 passed (459)
pnpm test:housekeeping
Test Files 58 passed (58)
Tests 502 passed (502)
pnpm test
Test Files 206 passed | 3 skipped (209)
Tests 1345 passed | 5 skipped (1350)
pnpm typecheck
tsc --noEmit
Exit 0
pnpm exec biome check --formatter-enabled=false <28 exact changed TypeScript/TSX files>
Checked 28 files. No fixes applied.
```
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
+1 -2
View File
@@ -14,8 +14,7 @@ export async function dismissApplication(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const rawId = formPositiveBigInt(formData, "id"); const rawId = formPositiveBigInt(formData, "id");
if (!rawId) return; if (!rawId) return;
const applicationId = Number(rawId); const applicationId = rawId.toString();
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
await peopleMutationService.execute( await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), createPeopleMutationInvocation(staff, createCorrelationId()),
+10 -2
View File
@@ -51,8 +51,8 @@ it("preserves all four IP actions and /admin revalidation", async () => {
"ip.action", "ip.action",
{ action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" }, { action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" },
], ],
["ip.action", { action: "delete-whitelist", id: 42 }], ["ip.action", { action: "delete-whitelist", id: "42" }],
["ip.action", { action: "delete-blacklist", id: 99 }], ["ip.action", { action: "delete-blacklist", id: "99" }],
]); ]);
expect(revalidatePath).toHaveBeenCalledTimes(4); expect(revalidatePath).toHaveBeenCalledTimes(4);
}); });
@@ -62,3 +62,11 @@ it("keeps empty IP input as a no-op after authorization", async () => {
expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit"); expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit");
expect(execute).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
}); });
it("preserves an IP rule ID above Number.MAX_SAFE_INTEGER", async () => {
await deleteBlacklist(form({ id: "9007199254740993" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "ip.action", {
action: "delete-blacklist",
id: "9007199254740993",
});
});
+4 -3
View File
@@ -7,6 +7,7 @@ import {
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
function parse(formData: FormData, key: string): string { function parse(formData: FormData, key: string): string {
@@ -26,16 +27,16 @@ async function run(
): Promise<void> { ): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT); const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const adding = action.startsWith("add-"); const adding = action.startsWith("add-");
const rawId = adding ? null : formPositiveBigInt(formData, "id");
const input = adding const input = adding
? { ? {
action, action,
ipAddress: parse(formData, "ipAddress"), ipAddress: parse(formData, "ipAddress"),
asn: parse(formData, "asn"), asn: parse(formData, "asn"),
} }
: { action, id: Number(formData.get("id")) }; : { action, id: rawId?.toString() ?? "" };
if (adding && !("ipAddress" in input && input.ipAddress)) return; if (adding && !("ipAddress" in input && input.ipAddress)) return;
const id = "id" in input ? input.id : undefined; if (!adding && !rawId) return;
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), createPeopleMutationInvocation(staff, createCorrelationId()),
"ip.action", "ip.action",
+9 -1
View File
@@ -50,7 +50,7 @@ it("preserves create and delete team payloads plus /admin revalidation", async (
hiddenRank: false, hiddenRank: false,
}, },
], ],
["team.change", { action: "delete", teamId: 42 }], ["team.change", { action: "delete", teamId: "42" }],
]); ]);
expect(revalidatePath).toHaveBeenCalledTimes(2); expect(revalidatePath).toHaveBeenCalledTimes(2);
}); });
@@ -59,3 +59,11 @@ it("preserves empty rank name as a no-op", async () => {
await createTeam(form({ rankName: "" })); await createTeam(form({ rankName: "" }));
expect(execute).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
}); });
it("preserves a team ID above Number.MAX_SAFE_INTEGER", async () => {
await deleteTeam(form({ id: "9007199254740993" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "team.change", {
action: "delete",
teamId: "9007199254740993",
});
});
+4 -2
View File
@@ -7,6 +7,7 @@ import {
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
function text(formData: FormData, key: string): string { function text(formData: FormData, key: string): string {
@@ -37,8 +38,9 @@ export async function createTeam(formData: FormData): Promise<void> {
export async function deleteTeam(formData: FormData): Promise<void> { export async function deleteTeam(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const teamId = Number(formData.get("id")); const rawTeamId = formPositiveBigInt(formData, "id");
if (!Number.isSafeInteger(teamId) || teamId <= 0) return; if (!rawTeamId) return;
const teamId = rawTeamId.toString();
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change", "team.change",
+4 -3
View File
@@ -7,6 +7,7 @@ import {
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
type ActionResult, type ActionResult,
@@ -36,9 +37,9 @@ export async function addWord(input: {
export async function deleteWord(input: { id: string }): Promise<ActionResult> { export async function deleteWord(input: { id: string }): Promise<ActionResult> {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT); const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const id = Number(String(input.id ?? "").normalize("NFC")); const parsedId = positiveBigInt(String(input.id ?? "").normalize("NFC"));
if (!Number.isSafeInteger(id) || id <= 0) if (!parsedId) return actionError("Missing word id");
return actionError("Missing word id"); const id = parsedId.toString();
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update", "word-filter.update",
+37 -5
View File
@@ -84,6 +84,13 @@ registerHousekeepingCommand({
ipAddress: commandContext.ipAddress, ipAddress: commandContext.ipAddress,
}, },
commandContext.correlationId, commandContext.correlationId,
input.value === "partial"
? {
status: "partial",
external: "failed",
audit: "persisted",
}
: undefined,
); );
}, },
}); });
@@ -136,6 +143,28 @@ describe("executeHousekeepingCommand", () => {
expect(sealRegistryMock).not.toHaveBeenCalled(); expect(sealRegistryMock).not.toHaveBeenCalled();
}); });
it("preserves one returned partial completion and its correlation through the real action dispatcher", async () => {
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
input: { value: "partial" },
});
expect(result).toMatchObject({
ok: true,
data: { value: "partial" },
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
expect(auditEntries).toHaveLength(1);
expect(auditEntries[0]).toMatchObject({
outcome: "partial",
correlationId: result.correlationId,
});
expect(() => JSON.stringify(result)).not.toThrow();
});
it("strictly rejects spoofed server-owned metadata before execution", async () => { it("strictly rejects spoofed server-owned metadata before execution", async () => {
const result = await executeHousekeepingCommand({ const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable", commandId: "system.server-action.serializable",
@@ -185,7 +214,7 @@ describe("executeHousekeepingCommand", () => {
expect(commandExecutions).toEqual([]); expect(commandExecutions).toEqual([]);
}); });
it("maps completed-operation audit failures to a typed partial result", async () => { it("returns one truthful partial completion when outcome audit persistence fails", async () => {
auditWriteMock.mockImplementation(async (entry: AuditEntry) => { auditWriteMock.mockImplementation(async (entry: AuditEntry) => {
if (entry.outcome === "success") { if (entry.outcome === "success") {
throw new Error("success audit unavailable"); throw new Error("success audit unavailable");
@@ -200,13 +229,16 @@ describe("executeHousekeepingCommand", () => {
expect(commandExecutions).toEqual(["changed"]); expect(commandExecutions).toEqual(["changed"]);
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: false, ok: true,
error: { data: { value: "changed" },
code: "INTERNAL", completion: {
messageKey: "errors.housekeeping.partial", status: "partial",
external: "not-required",
audit: "persisted",
}, },
}); });
expect(auditEntries.map((entry) => entry.outcome)).toEqual(["partial"]); expect(auditEntries.map((entry) => entry.outcome)).toEqual(["partial"]);
expect(auditEntries[0]?.correlationId).toBe(result.correlationId); expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
expect(() => JSON.stringify(result)).not.toThrow();
}); });
}); });
-25
View File
@@ -1,10 +1,8 @@
"use server"; "use server";
import "@/features/housekeeping/foundation/commands/bootstrap"; import "@/features/housekeeping/foundation/commands/bootstrap";
import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope";
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher"; import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
import { import {
fail,
type HousekeepingResult, type HousekeepingResult,
mapUnknownError, mapUnknownError,
} from "@/features/housekeeping/foundation/contracts"; } from "@/features/housekeeping/foundation/contracts";
@@ -29,29 +27,6 @@ export async function executeHousekeepingCommand(
(await rateLimit(key, attempts, windowMs)).ok, (await rateLimit(key, attempts, windowMs)).ok,
}); });
} catch (error) { } catch (error) {
if (
error instanceof AuditOutcomePersistenceError &&
isHousekeepingResult(error.operationResult)
) {
return fail(
"INTERNAL",
"errors.housekeeping.partial",
error.operationResult.correlationId,
);
}
return mapUnknownError(error); return mapUnknownError(error);
} }
} }
function isHousekeepingResult(
value: unknown,
): value is HousekeepingResult<unknown> {
return (
typeof value === "object" &&
value !== null &&
"ok" in value &&
typeof (value as { ok?: unknown }).ok === "boolean" &&
"correlationId" in value &&
typeof (value as { correlationId?: unknown }).correlationId === "string"
);
}
+13 -2
View File
@@ -114,11 +114,22 @@ describe("legacy application and word-filter wrappers", () => {
expect(execute).toHaveBeenCalledWith( expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1 }), expect.objectContaining({ expectedActorId: 1 }),
"application.decide", "application.decide",
{ applicationId: 9, decision: "dismiss" }, { applicationId: "9", decision: "dismiss" },
); );
expect(revalidatePath).toHaveBeenCalledWith("/admin/applications"); expect(revalidatePath).toHaveBeenCalledWith("/admin/applications");
}); });
it("preserves application and wordfilter IDs above Number.MAX_SAFE_INTEGER", async () => {
await dismissApplication(form({ id: "9007199254740993" }));
await expect(deleteWord({ id: "9007199254740993" })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
{ applicationId: "9007199254740993", decision: "dismiss" },
{ action: "delete", id: "9007199254740993" },
]);
});
it("preserves word-filter ActionResult shapes and /admin revalidation", async () => { it("preserves word-filter ActionResult shapes and /admin revalidation", async () => {
await expect(addWord({ word: "spam" })).resolves.toEqual({ await expect(addWord({ word: "spam" })).resolves.toEqual({
ok: true, ok: true,
@@ -130,7 +141,7 @@ describe("legacy application and word-filter wrappers", () => {
}); });
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([ expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
{ action: "add", word: "spam" }, { action: "add", word: "spam" },
{ action: "delete", id: 12 }, { action: "delete", id: "12" },
]); ]);
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter"); expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
}); });
@@ -95,6 +95,50 @@ describe("People community and staff commands", () => {
}, },
); );
it.each([
[
"people.application.decide",
{ applicationId: "9007199254740993", decision: "dismiss" },
],
["people.team.change", { action: "delete", teamId: "9007199254740993" }],
[
"people.ip.action",
{ action: "delete-blacklist", id: "9007199254740993" },
],
["people.word-filter.update", { action: "delete", id: "9007199254740993" }],
] as const)(
"accepts a canonical BIGINT identifier for %s",
(commandId, input) => {
const command = commands.find((entry) => entry.id === commandId);
if (!command) throw new Error(`command missing: ${commandId}`);
expect(command.input.safeParse(input).success).toBe(true);
},
);
it.each([
[
"people.application.decide",
{ applicationId: "18446744073709551616", decision: "dismiss" },
],
[
"people.team.change",
{ action: "delete", teamId: "18446744073709551616" },
],
[
"people.ip.action",
{ action: "delete-blacklist", id: "18446744073709551616" },
],
[
"people.word-filter.update",
{ action: "delete", id: "18446744073709551616" },
],
] as const)(
"rejects an overflowing BIGINT identifier for %s",
(commandId, input) => {
const command = commands.find((entry) => entry.id === commandId);
if (!command) throw new Error(`command missing: ${commandId}`);
expect(command.input.safeParse(input).success).toBe(false);
},
);
it("delegates the canonical operation without a redirect", async () => { it("delegates the canonical operation without a redirect", async () => {
const execute = vi.fn(async (context, operation, input) => ({ const execute = vi.fn(async (context, operation, input) => ({
ok: true as const, ok: true as const,
@@ -53,6 +53,30 @@ function communityCommand<I>(
} }
const positiveId = z.number().int().positive(); const positiveId = z.number().int().positive();
const MAX_UNSIGNED_BIGINT = "18446744073709551615";
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 unsignedBigIntId = z
.string()
.regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u); const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
export function createCommunityCommands( export function createCommunityCommands(
@@ -70,7 +94,7 @@ export function createCommunityCommands(
operation: "application.decide", operation: "application.decide",
capability: PERMS.USERS_EDIT, capability: PERMS.USERS_EDIT,
input: z.object({ input: z.object({
applicationId: positiveId, applicationId: unsignedBigIntId,
decision: z.literal("dismiss"), decision: z.literal("dismiss"),
}), }),
}), }),
@@ -87,7 +111,7 @@ export function createCommunityCommands(
staffColor: z.string().min(1).max(255).optional(), staffColor: z.string().min(1).max(255).optional(),
hiddenRank: z.boolean().optional(), hiddenRank: z.boolean().optional(),
}), }),
z.object({ action: z.literal("delete"), teamId: positiveId }), z.object({ action: z.literal("delete"), teamId: unsignedBigIntId }),
]), ]),
}), }),
communityCommand(service, { communityCommand(service, {
@@ -102,7 +126,7 @@ export function createCommunityCommands(
}), }),
z.object({ z.object({
action: z.enum(["delete-whitelist", "delete-blacklist"]), action: z.enum(["delete-whitelist", "delete-blacklist"]),
id: positiveId, id: unsignedBigIntId,
}), }),
]), ]),
}), }),
@@ -123,7 +147,7 @@ export function createCommunityCommands(
capability: PERMS.WORDFILTER_EDIT, capability: PERMS.WORDFILTER_EDIT,
input: z.discriminatedUnion("action", [ input: z.discriminatedUnion("action", [
z.object({ action: z.literal("add"), word: requiredText(255) }), z.object({ action: z.literal("add"), word: requiredText(255) }),
z.object({ action: z.literal("delete"), id: positiveId }), z.object({ action: z.literal("delete"), id: unsignedBigIntId }),
]), ]),
}), }),
] as const; ] as const;
@@ -111,7 +111,7 @@ export interface PeopleGuildDetail extends PeopleGuildSummary {
} }
export interface PeopleStaffApplication { export interface PeopleStaffApplication {
readonly id: number; readonly id: string;
readonly userId: number; readonly userId: number;
readonly username: string | null; readonly username: string | null;
readonly rankId: number; readonly rankId: number;
@@ -120,9 +120,9 @@ export interface PeopleStaffApplication {
} }
export interface PeopleTeam { export interface PeopleTeam {
readonly id: number; readonly id: string;
readonly name: string; readonly name: string;
readonly rank: number; readonly rank: string;
readonly hidden: boolean; readonly hidden: boolean;
readonly badge: string | null; readonly badge: string | null;
readonly jobDescription: string | null; readonly jobDescription: string | null;
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
vi.mock("@/actions/housekeeping-command", () => ({ vi.mock("@/actions/housekeeping-command", () => ({
@@ -26,6 +27,10 @@ import {
import { PeopleCommunityPage } from "./community"; import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts"; import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state"; import { parsePeopleListInput } from "./page-state";
import {
PeopleCommandResult,
submitPeopleCommandForm,
} from "./people-command-form";
import { PeopleStaffPage } from "./staff"; import { PeopleStaffPage } from "./staff";
import { PeopleUserDetailPage } from "./user-detail"; import { PeopleUserDetailPage } from "./user-detail";
import { PeopleUserEditPage } from "./user-edit"; import { PeopleUserEditPage } from "./user-edit";
@@ -215,7 +220,7 @@ const cases: readonly PageCase[] = [
page: { page: {
items: [ items: [
{ {
id: 5, id: "5",
userId: 7, userId: 7,
username: "Alice", username: "Alice",
rankId: 4, rankId: 4,
@@ -342,17 +347,58 @@ describe("People URL list input", () => {
}); });
}); });
describe("People actionable form contract", () => { describe("People actionable form contract", () => {
it("submits bounded command input and reason through the existing server action", () => { it("submits bounded command input and reason through the actual form action adapter", async () => {
const source = readFileSync( vi.mocked(executeHousekeepingCommand).mockResolvedValue(
"src/features/housekeeping/domains/people/pages/people-command-form.tsx", ok({ before: null, after: { amount: 100 } }, "form-action"),
"utf8",
); );
expect(source).toContain("executeHousekeepingCommand({"); const formData = new FormData();
expect(source).toContain("commandId"); formData.set("amount", "999");
expect(source).toContain("input: commandInput"); formData.set("reason", " Planned grant ");
expect(source).toContain("{ reason }");
expect(source).toContain("action={submit}"); const result = await submitPeopleCommandForm(
expect(source).not.toContain("<span data-housekeeping-command"); {
commandId: "people.user.send-currency",
input: { userId: 7 },
fields: [
{
name: "amount",
label: "Amount",
type: "number",
min: 1,
max: 100,
},
],
requiresReason: true,
includeReasonInInput: true,
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "people.user.send-currency",
input: { userId: 7, amount: 100, reason: "Planned grant" },
reason: "Planned grant",
});
expect(result).toMatchObject({ ok: true });
});
it("renders a generated reset credential once in the current accessible result", () => {
const password = "one-time-secret";
const html = renderToStaticMarkup(
<PeopleCommandResult
result={ok(
{
before: { passwordReset: false },
after: { passwordReset: true },
output: { newPassword: password },
},
"reset-result",
)}
/>,
);
expect(html).toContain('aria-label="Generated temporary password"');
expect(html.match(new RegExp(password, "gu"))).toHaveLength(1);
}); });
it("provides a real Next loading boundary", () => { it("provides a real Next loading boundary", () => {
@@ -427,7 +427,7 @@ describe("People community and staff queries", () => {
loadApplications: async () => ({ loadApplications: async () => ({
rows: [ rows: [
{ {
id: 11, id: "9007199254740993",
userId: 5, userId: 5,
username: "Applicant", username: "Applicant",
rankId: 3, rankId: 3,
@@ -456,7 +456,10 @@ describe("People community and staff queries", () => {
expect(applications).toMatchObject({ expect(applications).toMatchObject({
ok: true, ok: true,
data: { kind: "applications", page: { items: [{ id: 11 }] } }, data: {
kind: "applications",
page: { items: [{ id: "9007199254740993" }] },
},
}); });
expect(teams).toMatchObject({ ok: true, data: { kind: "teams" } }); expect(teams).toMatchObject({ ok: true, data: { kind: "teams" } });
expect(moderationTeam).toMatchObject({ expect(moderationTeam).toMatchObject({
@@ -169,7 +169,7 @@ const peopleStaffAdapters: PeopleStaffAdapters = {
content: string; content: string;
createdAt: Date | string | null; createdAt: Date | string | null;
}>(rowsResult).map((row) => ({ }>(rowsResult).map((row) => ({
id: Number(row.id), id: String(row.id),
userId: Number(row.userId), userId: Number(row.userId),
username: row.username, username: row.username,
rankId: Number(row.rankId), rankId: Number(row.rankId),
@@ -210,9 +210,9 @@ const peopleStaffAdapters: PeopleStaffAdapters = {
badge: string | null; badge: string | null;
jobDescription: string | null; jobDescription: string | null;
}>(rowsResult).map((row) => ({ }>(rowsResult).map((row) => ({
id: Number(row.id), id: String(row.id),
name: row.name, name: row.name,
rank: Number(row.id), rank: String(row.id),
hidden: Boolean(row.hidden), hidden: Boolean(row.hidden),
badge: row.badge, badge: row.badge,
jobDescription: row.jobDescription, jobDescription: row.jobDescription,
File diff suppressed because it is too large. Load diff
@@ -99,14 +99,14 @@ describe("People production mutation boundary", () => {
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: true, ok: true,
data: { data: {
before: { id: 7, username: "Alice", online: true }, before: null,
after: { id: 7, alertDelivered: true }, after: { userId: 7, alertDelivered: true },
}, },
}); });
expect(audit).toHaveBeenCalledWith( expect(audit).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
action: "people.user.alert", action: "people.user.alert",
before: expect.objectContaining({ id: 7 }), before: undefined,
after: expect.objectContaining({ alertDelivered: true }), after: expect.objectContaining({ alertDelivered: true }),
correlationId: "production-alert", correlationId: "production-alert",
outcome: "success", outcome: "success",
@@ -10,6 +10,7 @@ const {
selectLimit, selectLimit,
transaction, transaction,
txSelectLimit, txSelectLimit,
txSelectOrderBy,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
audit: vi.fn(), audit: vi.fn(),
disconnectUser: vi.fn(), disconnectUser: vi.fn(),
@@ -18,6 +19,7 @@ const {
selectLimit: vi.fn(), selectLimit: vi.fn(),
transaction: vi.fn(), transaction: vi.fn(),
txSelectLimit: vi.fn(), txSelectLimit: vi.fn(),
txSelectOrderBy: vi.fn(),
})); }));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
@@ -30,7 +32,7 @@ vi.mock("@/lib/db", async (importOriginal) => {
const tx = { const tx = {
select: vi.fn(() => ({ select: vi.fn(() => ({
from: vi.fn(() => ({ from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })), where: vi.fn(() => ({ orderBy: txSelectOrderBy })),
})), })),
})), })),
insert: vi.fn(() => ({ values: insertValues })), insert: vi.fn(() => ({ values: insertValues })),
@@ -87,7 +89,24 @@ beforeEach(() => {
online: "1", online: "1",
}, },
]); ]);
txSelectLimit.mockResolvedValue([]); txSelectLimit
.mockResolvedValueOnce([
{
id: 88,
banExpire: 0,
banReason: "Permanent active ban",
type: "account",
},
])
.mockResolvedValue([
{
id: 89,
banExpire: Math.floor(Date.now() / 1000) + 24 * 3600,
banReason: "Repeated harassment",
type: "account",
},
]);
txSelectOrderBy.mockReturnValue({ limit: txSelectLimit });
insertValues.mockResolvedValue([{}]); insertValues.mockResolvedValue([{}]);
disconnectUser.mockResolvedValue(true); disconnectUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined); audit.mockResolvedValue(undefined);
@@ -112,7 +131,12 @@ describe("People production reason audit", () => {
expect.objectContaining({ expect.objectContaining({
action: "people.user.ban", action: "people.user.ban",
outcome: "intent", outcome: "intent",
before: expect.objectContaining({ id: 7, banned: false }), before: expect.objectContaining({
id: 7,
banned: true,
banExpire: 0,
reason: "Permanent active ban",
}),
after: expect.objectContaining({ after: expect.objectContaining({
id: 7, id: 7,
banned: true, banned: true,
@@ -121,6 +145,7 @@ describe("People production reason audit", () => {
}), }),
expect.anything(), expect.anything(),
); );
expect(txSelectOrderBy).toHaveBeenCalledTimes(2);
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail"); expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail"); expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail");
}); });
@@ -109,7 +109,7 @@ describe("People transactional database audit", () => {
expect(audit).toHaveBeenCalledWith( expect(audit).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
outcome: "success", outcome: "success",
before: expect.objectContaining({ id: 9 }), before: expect.objectContaining({ id: "9" }),
after: undefined, after: undefined,
}), }),
expect.objectContaining({ insert: expect.any(Function) }), expect.objectContaining({ insert: expect.any(Function) }),
File diff suppressed because it is too large. Load diff
@@ -780,7 +780,7 @@ describe("dispatchHousekeepingCommand", () => {
expect(audit.entries.map((entry) => entry.outcome)).toEqual(["failure"]); expect(audit.entries.map((entry) => entry.outcome)).toEqual(["failure"]);
}); });
it("throws completed-operation evidence and persists partial when success audit rejects", async () => { it("returns completed-operation evidence and persists partial when success audit rejects", async () => {
const attempts: string[] = []; const attempts: string[] = [];
const persisted: string[] = []; const persisted: string[] = [];
register( register(
@@ -806,10 +806,14 @@ describe("dispatchHousekeepingCommand", () => {
}), }),
); );
await expect(dispatch).rejects.toMatchObject({ await expect(dispatch).resolves.toMatchObject({
name: "AuditOutcomePersistenceError", ok: true,
operationCompleted: true, data: { changed: true },
operationResult: { ok: true, data: { changed: true } }, completion: {
status: "partial",
external: "not-required",
audit: "persisted",
},
}); });
expect(attempts).toEqual(["success", "partial"]); expect(attempts).toEqual(["success", "partial"]);
expect(persisted).toEqual(["partial"]); expect(persisted).toEqual(["partial"]);
File diff suppressed because it is too large. Load diff
@@ -28,6 +28,7 @@ export {
fail, fail,
type HousekeepingError, type HousekeepingError,
type HousekeepingErrorCode, type HousekeepingErrorCode,
type HousekeepingPartialCompletion,
type HousekeepingResult, type HousekeepingResult,
mapUnknownError, mapUnknownError,
ok, ok,
@@ -17,12 +17,32 @@ export interface HousekeepingError {
fieldErrors?: Readonly<Record<string, readonly string[]>>; fieldErrors?: Readonly<Record<string, readonly string[]>>;
} }
export interface HousekeepingPartialCompletion {
readonly status: "partial";
readonly external: "not-required" | "completed" | "failed";
readonly audit: "persisted" | "unavailable";
}
export type HousekeepingResult<T> = export type HousekeepingResult<T> =
| { ok: true; data: T; correlationId: string } | {
ok: true;
data: T;
correlationId: string;
completion?: HousekeepingPartialCompletion;
}
| { ok: false; error: HousekeepingError; correlationId: string }; | { ok: false; error: HousekeepingError; correlationId: string };
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> { export function ok<T>(
return { ok: true, data, correlationId }; data: T,
correlationId: string,
completion?: HousekeepingPartialCompletion,
): HousekeepingResult<T> {
return {
ok: true,
data,
correlationId,
...(completion === undefined ? {} : { completion }),
};
} }
export function fail( export function fail(
@@ -39,7 +39,6 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/multi-accounts.tsx", "src/features/housekeeping/domains/people/pages/multi-accounts.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/users", "src/features/housekeeping/domains/people/queries/users",
]), ]),
+5 -1
View File
@@ -94,13 +94,17 @@ describe("logAudit", () => {
action: "update", action: "update",
target: "user", target: "user",
before: { before: {
profile: { authTicket: "private-ticket" }, profile: {
authTicket: "private-ticket",
credentials: { newPassword: "one-time-password" },
},
integrations: [{ api_key: "private-key" }], integrations: [{ api_key: "private-key" }],
}, },
}); });
const before = JSON.parse(insertValues.mock.calls[0][0].before); const before = JSON.parse(insertValues.mock.calls[0][0].before);
expect(before.profile.authTicket).toBe("[Redacted]"); expect(before.profile.authTicket).toBe("[Redacted]");
expect(before.profile.credentials.newPassword).toBe("[Redacted]");
expect(before.integrations[0].api_key).toBe("[Redacted]"); expect(before.integrations[0].api_key).toBe("[Redacted]");
}); });