feat(housekeeping): dispatch audited commands

This commit is contained in:
Simo committed 2026-08-27 18:30:44 +02:00
1 parent ca666d9f90
commit 6aed71a8b2
8 files changed
+996

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
import {
anyCapability,
ok,
} from "@/features/housekeeping/foundation/contracts";
import type { AuditEntry } from "@/lib/services/audit";
const { auditEntries, context, rateLimitCalls } = vi.hoisted(() => ({
auditEntries: [] as AuditEntry[],
context: {
actor: { id: 71, username: "server-operator", rank: 4 },
isSuperAdmin: false,
has: (slug: string) => slug === "admin.settings.edit",
hasAny: (...slugs: string[]) => slugs.includes("admin.settings.edit"),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => slug === "admin.settings.edit"),
},
rateLimitCalls: [] as Array<[string, number, number]>,
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: async () => context,
}));
vi.mock("@/lib/services/audit", () => ({
housekeepingAuditWriter: {
write: async (entry: AuditEntry) => {
auditEntries.push({ ...entry });
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
clientIp: async () => "203.0.113.7",
rateLimit: async (key: string, attempts: number, windowMs: number) => {
rateLimitCalls.push([key, attempts, windowMs]);
return { ok: true, retryAfter: 0 };
},
}));
import { executeHousekeepingCommand } from "./housekeeping-command";
registerHousekeepingCommand({
id: "system.server-action.serializable",
owner: "system",
risk: "safe",
capability: anyCapability("admin.settings.edit"),
input: z.object({ value: z.string() }),
requiresReason: false,
rateLimit: { attempts: 5, windowMs: 120_000 },
execute: async (commandContext, input) =>
ok(
{
value: input.value,
actorId: commandContext.capability.actor.id,
ipAddress: commandContext.ipAddress,
},
commandContext.correlationId,
),
});
beforeEach(() => {
auditEntries.length = 0;
rateLimitCalls.length = 0;
});
describe("executeHousekeepingCommand", () => {
it("accepts a plain request and ignores spoofed server-owned metadata", async () => {
const request = {
commandId: "system.server-action.serializable",
input: { value: "saved" },
risk: "sensitive",
owner: "people",
capability: { mode: "any", slugs: ["forged.permission"] },
actor: { id: 999 },
ipAddress: "198.51.100.9",
rateLimit: { attempts: 999, windowMs: 1 },
audit: { action: "forged.action", target: "forged-target" },
};
const result = await executeHousekeepingCommand(request);
expect(result).toMatchObject({
ok: true,
data: {
value: "saved",
actorId: 71,
ipAddress: "203.0.113.7",
},
});
expect(rateLimitCalls).toEqual([
[
"housekeeping-command:71:203.0.113.7:system.server-action.serializable",
5,
120_000,
],
]);
expect(auditEntries).toMatchObject([
{
userId: 71,
action: "system.server-action.serializable",
target: "system",
domain: "system",
ipAddress: "203.0.113.7",
outcome: "success",
},
]);
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
});
});
+27
View File
@@ -0,0 +1,27 @@
"use server";
import {
dispatchHousekeepingCommand,
type HousekeepingCommandRequest,
} from "@/features/housekeeping/foundation/commands/dispatcher";
import type { HousekeepingResult } from "@/features/housekeeping/foundation/contracts";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { housekeepingAuditWriter } from "@/lib/services/audit";
export async function executeHousekeepingCommand(
request: HousekeepingCommandRequest,
): Promise<HousekeepingResult<unknown>> {
const [context, ipAddress] = await Promise.all([
getHousekeepingCapabilityContext(),
clientIp(),
]);
return dispatchHousekeepingCommand(request, {
context,
ipAddress,
audit: housekeepingAuditWriter,
rateLimit: async (key, attempts, windowMs) =>
(await rateLimit(key, attempts, windowMs)).ok,
});
}