229 lines
7.1 KiB
TypeScript
229 lines
7.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
|
|
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
|
|
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
|
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
|
|
|
import {
|
|
createPeopleMutationService,
|
|
type PeopleMutationAdapter,
|
|
} from "../services/mutations";
|
|
import {
|
|
createUserCommands,
|
|
USER_COMMAND_IDS,
|
|
USER_COMMANDS,
|
|
} from "./user-commands";
|
|
|
|
const expectedUserCommandIds = [
|
|
"people.user.update",
|
|
"people.user.ban",
|
|
"people.user.unban",
|
|
"people.user.alert",
|
|
"people.user.disconnect",
|
|
"people.user.mute",
|
|
"people.user.unmute",
|
|
"people.user.reset-password",
|
|
"people.user.send-currency",
|
|
"people.user.trade-lock",
|
|
"people.users.bulk-ban",
|
|
"people.users.bulk-unban",
|
|
"people.users.bulk-currency",
|
|
"people.users.bulk-badge",
|
|
] as const;
|
|
|
|
const commands = USER_COMMANDS as unknown as readonly HousekeepingCommand<
|
|
unknown,
|
|
unknown
|
|
>[];
|
|
|
|
function capabilityContext(
|
|
granted: readonly string[],
|
|
): HousekeepingCapabilityContext {
|
|
const permissions = new Set(granted);
|
|
return {
|
|
actor: { id: 42, username: "operator", rank: 6 },
|
|
isSuperAdmin: false,
|
|
has: (slug) => permissions.has(slug),
|
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
|
};
|
|
}
|
|
|
|
describe("People user commands", () => {
|
|
it("declares the exact deterministic user command list", () => {
|
|
expect(USER_COMMAND_IDS).toEqual(expectedUserCommandIds);
|
|
expect(commands.map((command) => command.id)).toEqual(
|
|
expectedUserCommandIds,
|
|
);
|
|
expect(commands.every((command) => command.owner === "people")).toBe(true);
|
|
expect(commands.every((command) => command.risk === "sensitive")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("uses the exact legacy ACL for every user operation", () => {
|
|
const capabilities = Object.fromEntries(
|
|
commands.map((command) => [command.id, command.capability.slugs]),
|
|
);
|
|
expect(capabilities).toEqual({
|
|
"people.user.update": [PERMS.USERS_EDIT],
|
|
"people.user.ban": [PERMS.USERS_BAN],
|
|
"people.user.unban": [PERMS.USERS_BAN],
|
|
"people.user.alert": [PERMS.USERS_EDIT],
|
|
"people.user.disconnect": [PERMS.USERS_EDIT],
|
|
"people.user.mute": [PERMS.USERS_EDIT],
|
|
"people.user.unmute": [PERMS.USERS_EDIT],
|
|
"people.user.reset-password": [PERMS.USERS_RESET_PASSWORD],
|
|
"people.user.send-currency": [PERMS.USERS_EDIT],
|
|
"people.user.trade-lock": [PERMS.USERS_EDIT],
|
|
"people.users.bulk-ban": [PERMS.USERS_EDIT],
|
|
"people.users.bulk-unban": [PERMS.USERS_EDIT],
|
|
"people.users.bulk-currency": [PERMS.USERS_EDIT],
|
|
"people.users.bulk-badge": [PERMS.USERS_EDIT],
|
|
});
|
|
});
|
|
|
|
it("requires reasons for sanctions, security changes, currency, and bulk mutations", () => {
|
|
const withoutReason = commands
|
|
.filter((command) => !command.requiresReason)
|
|
.map((command) => command.id);
|
|
expect(withoutReason).toEqual(["people.user.update", "people.user.alert"]);
|
|
|
|
for (const command of commands.filter((entry) => entry.requiresReason)) {
|
|
expect(
|
|
confirmHousekeepingCommand(command, " ", "people-reason"),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: "VALIDATION",
|
|
fieldErrors: { reason: ["errors.validation.required"] },
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["people.user.update", { userId: 7, fields: {} }],
|
|
["people.user.alert", { userId: 7, message: " " }],
|
|
["people.user.mute", { userId: 7, duration: 87_601 }],
|
|
["people.user.send-currency", { userId: 7, amount: 1_000_001 }],
|
|
["people.users.bulk-ban", { userIds: [], reason: "reason", duration: 0 }],
|
|
[
|
|
"people.users.bulk-badge",
|
|
{
|
|
userIds: Array.from({ length: 101 }, (_, index) => index + 1),
|
|
badgeCode: "ADM",
|
|
},
|
|
],
|
|
] as const)(
|
|
"rejects an invalid bounded payload 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 parsed input and correlation to the redirect-free service", async () => {
|
|
const execute = vi.fn(async (context, operation, input) => ({
|
|
ok: true as const,
|
|
data: { before: null, after: { operation, input } },
|
|
correlationId: context.correlationId,
|
|
}));
|
|
const created = createUserCommands({
|
|
execute,
|
|
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
|
const command = created.find(
|
|
(entry) => entry.id === "people.user.send-currency",
|
|
);
|
|
if (!command) throw new Error("command missing");
|
|
const input = command.input.parse({ userId: 7, amount: 25 });
|
|
const result = await command.execute(
|
|
{
|
|
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
correlationId: "people-command",
|
|
ipAddress: "198.51.100.8",
|
|
},
|
|
input,
|
|
);
|
|
|
|
expect(execute).toHaveBeenCalledWith(
|
|
expect.objectContaining({ correlationId: "people-command" }),
|
|
"user.send-currency",
|
|
{ userId: 7, amount: 25 },
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: { before: null, after: { operation: "user.send-currency" } },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("People mutation service boundary", () => {
|
|
it("fails closed before the adapter when the exact capability is absent", async () => {
|
|
const execute = vi.fn(async () => ({ before: null, after: null }));
|
|
const service = createPeopleMutationService({ execute });
|
|
const result = await service.execute(
|
|
{
|
|
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
correlationId: "denied-people",
|
|
},
|
|
"user.ban",
|
|
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "FORBIDDEN" },
|
|
correlationId: "denied-people",
|
|
});
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns adapter before and after snapshots and sanitizes failures", async () => {
|
|
const snapshotAdapter: PeopleMutationAdapter = {
|
|
execute: async () => ({
|
|
before: { rank: 2 },
|
|
after: { rank: 3 },
|
|
}),
|
|
};
|
|
const success = await createPeopleMutationService(snapshotAdapter).execute(
|
|
{
|
|
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
correlationId: "snapshot-people",
|
|
},
|
|
"user.update",
|
|
{ userId: 7, fields: { rank: 3 } },
|
|
);
|
|
expect(success).toMatchObject({
|
|
ok: true,
|
|
data: { before: { rank: 2 }, after: { rank: 3 } },
|
|
});
|
|
|
|
const failureAdapter: PeopleMutationAdapter = {
|
|
execute: async () => {
|
|
throw new Error("database password=secret");
|
|
},
|
|
};
|
|
const failure = await createPeopleMutationService(failureAdapter).execute(
|
|
{
|
|
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
correlationId: "failed-people",
|
|
},
|
|
"user.update",
|
|
{ userId: 7, fields: { motto: "Ready" } },
|
|
);
|
|
expect(failure).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: "DEPENDENCY_UNAVAILABLE",
|
|
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
},
|
|
});
|
|
expect(JSON.stringify(failure)).not.toContain("password=secret");
|
|
});
|
|
});
|