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
+1365 -290

No files matched your search

@@ -205,4 +205,53 @@ git diff --check e1b31ff7738eb5cc59e7765c8ed7290d62130972 --
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.
## 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 rawId = formPositiveBigInt(formData, "id");
if (!rawId) return;
const applicationId = Number(rawId);
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
const applicationId = rawId.toString();
await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
+10 -2
View File
@@ -51,8 +51,8 @@ it("preserves all four IP actions and /admin revalidation", async () => {
"ip.action",
{ action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" },
],
["ip.action", { action: "delete-whitelist", id: 42 }],
["ip.action", { action: "delete-blacklist", id: 99 }],
["ip.action", { action: "delete-whitelist", id: "42" }],
["ip.action", { action: "delete-blacklist", id: "99" }],
]);
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(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";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
function parse(formData: FormData, key: string): string {
@@ -26,16 +27,16 @@ async function run(
): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const adding = action.startsWith("add-");
const rawId = adding ? null : formPositiveBigInt(formData, "id");
const input = adding
? {
action,
ipAddress: parse(formData, "ipAddress"),
asn: parse(formData, "asn"),
}
: { action, id: Number(formData.get("id")) };
: { action, id: rawId?.toString() ?? "" };
if (adding && !("ipAddress" in input && input.ipAddress)) return;
const id = "id" in input ? input.id : undefined;
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
if (!adding && !rawId) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ip.action",
+9 -1
View File
@@ -50,7 +50,7 @@ it("preserves create and delete team payloads plus /admin revalidation", async (
hiddenRank: false,
},
],
["team.change", { action: "delete", teamId: 42 }],
["team.change", { action: "delete", teamId: "42" }],
]);
expect(revalidatePath).toHaveBeenCalledTimes(2);
});
@@ -59,3 +59,11 @@ it("preserves empty rank name as a no-op", async () => {
await createTeam(form({ rankName: "" }));
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";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
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> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const teamId = Number(formData.get("id"));
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
const rawTeamId = formPositiveBigInt(formData, "id");
if (!rawTeamId) return;
const teamId = rawTeamId.toString();
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
+4 -3
View File
@@ -7,6 +7,7 @@ import {
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
@@ -36,9 +37,9 @@ export async function addWord(input: {
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const id = Number(String(input.id ?? "").normalize("NFC"));
if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id");
const parsedId = positiveBigInt(String(input.id ?? "").normalize("NFC"));
if (!parsedId) return actionError("Missing word id");
const id = parsedId.toString();
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
+37 -5
View File
@@ -84,6 +84,13 @@ registerHousekeepingCommand({
ipAddress: commandContext.ipAddress,
},
commandContext.correlationId,
input.value === "partial"
? {
status: "partial",
external: "failed",
audit: "persisted",
}
: undefined,
);
},
});
@@ -136,6 +143,28 @@ describe("executeHousekeepingCommand", () => {
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 () => {
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
@@ -185,7 +214,7 @@ describe("executeHousekeepingCommand", () => {
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) => {
if (entry.outcome === "success") {
throw new Error("success audit unavailable");
@@ -200,13 +229,16 @@ describe("executeHousekeepingCommand", () => {
expect(commandExecutions).toEqual(["changed"]);
expect(result).toMatchObject({
ok: false,
error: {
code: "INTERNAL",
messageKey: "errors.housekeeping.partial",
ok: true,
data: { value: "changed" },
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
},
});
expect(auditEntries.map((entry) => entry.outcome)).toEqual(["partial"]);
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
expect(() => JSON.stringify(result)).not.toThrow();
});
});
-25
View File
@@ -1,10 +1,8 @@
"use server";
import "@/features/housekeeping/foundation/commands/bootstrap";
import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope";
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
import {
fail,
type HousekeepingResult,
mapUnknownError,
} from "@/features/housekeeping/foundation/contracts";
@@ -29,29 +27,6 @@ export async function executeHousekeepingCommand(
(await rateLimit(key, attempts, windowMs)).ok,
});
} catch (error) {
if (
error instanceof AuditOutcomePersistenceError &&
isHousekeepingResult(error.operationResult)
) {
return fail(
"INTERNAL",
"errors.housekeeping.partial",
error.operationResult.correlationId,
);
}
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.objectContaining({ expectedActorId: 1 }),
"application.decide",
{ applicationId: 9, decision: "dismiss" },
{ applicationId: "9", decision: "dismiss" },
);
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 () => {
await expect(addWord({ word: "spam" })).resolves.toEqual({
ok: true,
@@ -130,7 +141,7 @@ describe("legacy application and word-filter wrappers", () => {
});
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
{ action: "add", word: "spam" },
{ action: "delete", id: 12 },
{ action: "delete", id: "12" },
]);
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 () => {
const execute = vi.fn(async (context, operation, input) => ({
ok: true as const,
@@ -53,6 +53,30 @@ function communityCommand<I>(
}
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);
export function createCommunityCommands(
@@ -70,7 +94,7 @@ export function createCommunityCommands(
operation: "application.decide",
capability: PERMS.USERS_EDIT,
input: z.object({
applicationId: positiveId,
applicationId: unsignedBigIntId,
decision: z.literal("dismiss"),
}),
}),
@@ -87,7 +111,7 @@ export function createCommunityCommands(
staffColor: z.string().min(1).max(255).optional(),
hiddenRank: z.boolean().optional(),
}),
z.object({ action: z.literal("delete"), teamId: positiveId }),
z.object({ action: z.literal("delete"), teamId: unsignedBigIntId }),
]),
}),
communityCommand(service, {
@@ -102,7 +126,7 @@ export function createCommunityCommands(
}),
z.object({
action: z.enum(["delete-whitelist", "delete-blacklist"]),
id: positiveId,
id: unsignedBigIntId,
}),
]),
}),
@@ -123,7 +147,7 @@ export function createCommunityCommands(
capability: PERMS.WORDFILTER_EDIT,
input: z.discriminatedUnion("action", [
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;
@@ -111,7 +111,7 @@ export interface PeopleGuildDetail extends PeopleGuildSummary {
}
export interface PeopleStaffApplication {
readonly id: number;
readonly id: string;
readonly userId: number;
readonly username: string | null;
readonly rankId: number;
@@ -120,9 +120,9 @@ export interface PeopleStaffApplication {
}
export interface PeopleTeam {
readonly id: number;
readonly id: string;
readonly name: string;
readonly rank: number;
readonly rank: string;
readonly hidden: boolean;
readonly badge: 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 { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
import { PERMS } from "@/lib/permission-slugs";
vi.mock("@/actions/housekeeping-command", () => ({
@@ -26,6 +27,10 @@ import {
import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state";
import {
PeopleCommandResult,
submitPeopleCommandForm,
} from "./people-command-form";
import { PeopleStaffPage } from "./staff";
import { PeopleUserDetailPage } from "./user-detail";
import { PeopleUserEditPage } from "./user-edit";
@@ -215,7 +220,7 @@ const cases: readonly PageCase[] = [
page: {
items: [
{
id: 5,
id: "5",
userId: 7,
username: "Alice",
rankId: 4,
@@ -342,17 +347,58 @@ describe("People URL list input", () => {
});
});
describe("People actionable form contract", () => {
it("submits bounded command input and reason through the existing server action", () => {
const source = readFileSync(
"src/features/housekeeping/domains/people/pages/people-command-form.tsx",
"utf8",
it("submits bounded command input and reason through the actual form action adapter", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { amount: 100 } }, "form-action"),
);
expect(source).toContain("executeHousekeepingCommand({");
expect(source).toContain("commandId");
expect(source).toContain("input: commandInput");
expect(source).toContain("{ reason }");
expect(source).toContain("action={submit}");
expect(source).not.toContain("<span data-housekeeping-command");
const formData = new FormData();
formData.set("amount", "999");
formData.set("reason", " Planned grant ");
const result = await submitPeopleCommandForm(
{
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", () => {
@@ -427,7 +427,7 @@ describe("People community and staff queries", () => {
loadApplications: async () => ({
rows: [
{
id: 11,
id: "9007199254740993",
userId: 5,
username: "Applicant",
rankId: 3,
@@ -456,7 +456,10 @@ describe("People community and staff queries", () => {
expect(applications).toMatchObject({
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(moderationTeam).toMatchObject({
@@ -169,7 +169,7 @@ const peopleStaffAdapters: PeopleStaffAdapters = {
content: string;
createdAt: Date | string | null;
}>(rowsResult).map((row) => ({
id: Number(row.id),
id: String(row.id),
userId: Number(row.userId),
username: row.username,
rankId: Number(row.rankId),
@@ -210,9 +210,9 @@ const peopleStaffAdapters: PeopleStaffAdapters = {
badge: string | null;
jobDescription: string | null;
}>(rowsResult).map((row) => ({
id: Number(row.id),
id: String(row.id),
name: row.name,
rank: Number(row.id),
rank: String(row.id),
hidden: Boolean(row.hidden),
badge: row.badge,
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({
ok: true,
data: {
before: { id: 7, username: "Alice", online: true },
after: { id: 7, alertDelivered: true },
before: null,
after: { userId: 7, alertDelivered: true },
},
});
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
action: "people.user.alert",
before: expect.objectContaining({ id: 7 }),
before: undefined,
after: expect.objectContaining({ alertDelivered: true }),
correlationId: "production-alert",
outcome: "success",
@@ -10,6 +10,7 @@ const {
selectLimit,
transaction,
txSelectLimit,
txSelectOrderBy,
} = vi.hoisted(() => ({
audit: vi.fn(),
disconnectUser: vi.fn(),
@@ -18,6 +19,7 @@ const {
selectLimit: vi.fn(),
transaction: vi.fn(),
txSelectLimit: vi.fn(),
txSelectOrderBy: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
@@ -30,7 +32,7 @@ vi.mock("@/lib/db", async (importOriginal) => {
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })),
where: vi.fn(() => ({ orderBy: txSelectOrderBy })),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
@@ -87,7 +89,24 @@ beforeEach(() => {
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([{}]);
disconnectUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
@@ -112,7 +131,12 @@ describe("People production reason audit", () => {
expect.objectContaining({
action: "people.user.ban",
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({
id: 7,
banned: true,
@@ -121,6 +145,7 @@ describe("People production reason audit", () => {
}),
expect.anything(),
);
expect(txSelectOrderBy).toHaveBeenCalledTimes(2);
expect(audit.mock.calls[0]?.[0]?.before).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.objectContaining({
outcome: "success",
before: expect.objectContaining({ id: 9 }),
before: expect.objectContaining({ id: "9" }),
after: undefined,
}),
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"]);
});
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 persisted: string[] = [];
register(
@@ -806,10 +806,14 @@ describe("dispatchHousekeepingCommand", () => {
}),
);
await expect(dispatch).rejects.toMatchObject({
name: "AuditOutcomePersistenceError",
operationCompleted: true,
operationResult: { ok: true, data: { changed: true } },
await expect(dispatch).resolves.toMatchObject({
ok: true,
data: { changed: true },
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
},
});
expect(attempts).toEqual(["success", "partial"]);
expect(persisted).toEqual(["partial"]);
File diff suppressed because it is too large. Load diff
@@ -28,6 +28,7 @@ export {
fail,
type HousekeepingError,
type HousekeepingErrorCode,
type HousekeepingPartialCompletion,
type HousekeepingResult,
mapUnknownError,
ok,
@@ -17,12 +17,32 @@ export interface HousekeepingError {
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> =
| { ok: true; data: T; correlationId: string }
| {
ok: true;
data: T;
correlationId: string;
completion?: HousekeepingPartialCompletion;
}
| { ok: false; error: HousekeepingError; correlationId: string };
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
return { ok: true, data, correlationId };
export function ok<T>(
data: T,
correlationId: string,
completion?: HousekeepingPartialCompletion,
): HousekeepingResult<T> {
return {
ok: true,
data,
correlationId,
...(completion === undefined ? {} : { completion }),
};
}
export function fail(
@@ -39,7 +39,6 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"src/features/housekeeping/domains/people/pages/multi-accounts.tsx",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/users",
]),
+5 -1
View File
@@ -94,13 +94,17 @@ describe("logAudit", () => {
action: "update",
target: "user",
before: {
profile: { authTicket: "private-ticket" },
profile: {
authTicket: "private-ticket",
credentials: { newPassword: "one-time-password" },
},
integrations: [{ api_key: "private-key" }],
},
});
const before = JSON.parse(insertValues.mock.calls[0][0].before);
expect(before.profile.authTicket).toBe("[Redacted]");
expect(before.profile.credentials.newPassword).toBe("[Redacted]");
expect(before.integrations[0].api_key).toBe("[Redacted]");
});