fix(housekeeping): harden command dispatch boundaries
This commit is contained in:
1 parent
796d009c07
commit
00618fb2a3
8 files changed
+608
-30
No files matched your search
@@ -1,6 +1,21 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
"@/features/housekeeping/foundation/commands/registry",
|
||||||
|
async (importOriginal) => ({
|
||||||
|
...(await importOriginal<
|
||||||
|
typeof import("@/features/housekeeping/foundation/commands/registry")
|
||||||
|
>()),
|
||||||
|
sealHousekeepingCommandRegistry: sealRegistryMock,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/features/housekeeping/foundation/commands/bootstrap", () => ({
|
||||||
|
housekeepingCommandRegistryReady: true,
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
anyCapability,
|
anyCapability,
|
||||||
ok,
|
ok,
|
||||||
@@ -15,6 +30,7 @@ const {
|
|||||||
getContextMock,
|
getContextMock,
|
||||||
getIpMock,
|
getIpMock,
|
||||||
rateLimitCalls,
|
rateLimitCalls,
|
||||||
|
sealRegistryMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
auditEntries: [] as AuditEntry[],
|
auditEntries: [] as AuditEntry[],
|
||||||
auditWriteMock: vi.fn(),
|
auditWriteMock: vi.fn(),
|
||||||
@@ -30,6 +46,7 @@ const {
|
|||||||
getContextMock: vi.fn(),
|
getContextMock: vi.fn(),
|
||||||
getIpMock: vi.fn(),
|
getIpMock: vi.fn(),
|
||||||
rateLimitCalls: [] as Array<[string, number, number]>,
|
rateLimitCalls: [] as Array<[string, number, number]>,
|
||||||
|
sealRegistryMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
@@ -77,6 +94,7 @@ beforeEach(() => {
|
|||||||
rateLimitCalls.length = 0;
|
rateLimitCalls.length = 0;
|
||||||
getContextMock.mockReset().mockResolvedValue(context);
|
getContextMock.mockReset().mockResolvedValue(context);
|
||||||
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
|
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
|
||||||
|
sealRegistryMock.mockReset();
|
||||||
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
|
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
|
||||||
auditEntries.push({ ...entry });
|
auditEntries.push({ ...entry });
|
||||||
});
|
});
|
||||||
@@ -115,6 +133,7 @@ describe("executeHousekeepingCommand", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
||||||
|
expect(sealRegistryMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strictly rejects spoofed server-owned metadata before execution", async () => {
|
it("strictly rejects spoofed server-owned metadata before execution", async () => {
|
||||||
@@ -136,7 +155,16 @@ describe("executeHousekeepingCommand", () => {
|
|||||||
});
|
});
|
||||||
expect(commandExecutions).toEqual([]);
|
expect(commandExecutions).toEqual([]);
|
||||||
expect(rateLimitCalls).toEqual([]);
|
expect(rateLimitCalls).toEqual([]);
|
||||||
expect(auditEntries).toEqual([]);
|
expect(auditEntries).toMatchObject([
|
||||||
|
{
|
||||||
|
userId: 71,
|
||||||
|
action: "housekeeping.command.dispatch",
|
||||||
|
target: "request-envelope",
|
||||||
|
ipAddress: "203.0.113.7",
|
||||||
|
outcome: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(auditEntries)).not.toContain("forged");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes server context acquisition failures into typed results", async () => {
|
it("sanitizes server context acquisition failures into typed results", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import "@/features/housekeeping/foundation/commands/bootstrap";
|
||||||
import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope";
|
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 { sealHousekeepingCommandRegistry } from "@/features/housekeeping/foundation/commands/registry";
|
|
||||||
import {
|
import {
|
||||||
fail,
|
fail,
|
||||||
type HousekeepingResult,
|
type HousekeepingResult,
|
||||||
@@ -15,7 +15,6 @@ import { housekeepingAuditWriter } from "@/lib/services/audit";
|
|||||||
export async function executeHousekeepingCommand(
|
export async function executeHousekeepingCommand(
|
||||||
request: unknown,
|
request: unknown,
|
||||||
): Promise<HousekeepingResult<unknown>> {
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
sealHousekeepingCommandRegistry();
|
|
||||||
try {
|
try {
|
||||||
const [context, ipAddress] = await Promise.all([
|
const [context, ipAddress] = await Promise.all([
|
||||||
getHousekeepingCapabilityContext(),
|
getHousekeepingCapabilityContext(),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anyCapability, ok } from "../contracts";
|
||||||
|
import { housekeepingCommandRegistryReady } from "./bootstrap";
|
||||||
|
import { registerHousekeepingCommand } from "./registry";
|
||||||
|
|
||||||
|
describe("housekeeping command bootstrap", () => {
|
||||||
|
it("registers the complete current list and seals during module initialization", () => {
|
||||||
|
expect(housekeepingCommandRegistryReady).toBe(true);
|
||||||
|
expect(() =>
|
||||||
|
registerHousekeepingCommand({
|
||||||
|
id: "system.bootstrap.too-late",
|
||||||
|
owner: "system",
|
||||||
|
risk: "safe",
|
||||||
|
capability: anyCapability("admin.settings.view"),
|
||||||
|
input: z.object({}),
|
||||||
|
requiresReason: false,
|
||||||
|
rateLimit: { attempts: 1, windowMs: 1_000 },
|
||||||
|
execute: async (context) => ok(null, context.correlationId),
|
||||||
|
}),
|
||||||
|
).toThrow("command registry is sealed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { HousekeepingCommand } from "./registry";
|
||||||
|
import {
|
||||||
|
registerHousekeepingCommand,
|
||||||
|
sealHousekeepingCommandRegistry,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
const currentHousekeepingCommands =
|
||||||
|
[] as const satisfies readonly HousekeepingCommand<unknown, unknown>[];
|
||||||
|
|
||||||
|
for (const command of currentHousekeepingCommands) {
|
||||||
|
registerHousekeepingCommand(command);
|
||||||
|
}
|
||||||
|
sealHousekeepingCommandRegistry();
|
||||||
|
|
||||||
|
export const housekeepingCommandRegistryReady = true;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -123,6 +123,37 @@ describe("housekeeping command registry", () => {
|
|||||||
expect(() => registerHousekeepingCommand(invalid)).toThrow();
|
expect(() => registerHousekeepingCommand(invalid)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps registered validation unchanged after original shape and child mutation", () => {
|
||||||
|
const child = z.string().min(3);
|
||||||
|
const input = z.object({ value: child, guard: child });
|
||||||
|
registerHousekeepingCommand({
|
||||||
|
...command("people.registry.deep-validation"),
|
||||||
|
input,
|
||||||
|
execute: async (context, value) =>
|
||||||
|
ok({ id: value.value.length }, context.correlationId),
|
||||||
|
} as HousekeepingCommand<{ value: string; guard: string }, { id: number }>);
|
||||||
|
const registered = getHousekeepingCommand(
|
||||||
|
"people.registry.deep-validation",
|
||||||
|
);
|
||||||
|
if (!registered) throw new Error("registered command missing");
|
||||||
|
|
||||||
|
(input.def as { shape: { value: z.ZodType } }).shape.value = z.number();
|
||||||
|
const minCheck = (child.def as { checks: unknown[] }).checks[0] as {
|
||||||
|
_zod: { def: { minimum: number } };
|
||||||
|
};
|
||||||
|
minCheck._zod.def.minimum = 0;
|
||||||
|
|
||||||
|
expect(input.safeParse({ value: 4, guard: "a" }).success).toBe(true);
|
||||||
|
expect(
|
||||||
|
registered.input.safeParse({ value: "abcd", guard: "ab" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
registered.input.safeParse({ value: "abcd", guard: "abcd" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
registered.input.safeParse({ value: 4, guard: "abcd" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
it("seals the global registry after deterministic bootstrap", () => {
|
it("seals the global registry after deterministic bootstrap", () => {
|
||||||
sealHousekeepingCommandRegistry();
|
sealHousekeepingCommandRegistry();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user