fix(housekeeping): complete people workflow fidelity
This commit is contained in:
1 parent
25b76437ff
commit
91c9efcbb4
29 files changed
+1365
-290
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.
|
||||
## 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.
|
||||
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+54
-47
File diff suppressed because it is too large.
Load diff
+539
-4
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",
|
||||
|
||||
+28
-3
@@ -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");
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
]),
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user