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
@@ -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");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user