151 lines
4.3 KiB
TypeScript
151 lines
4.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import type { AuditEntry } from "@/lib/services/audit";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
|
|
const { alertUser, audit, resolveServerContext } = vi.hoisted(() => ({
|
|
alertUser: vi.fn(),
|
|
audit: vi.fn(),
|
|
resolveServerContext: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
|
getHousekeepingCapabilityContext: resolveServerContext,
|
|
}));
|
|
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
|
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
|
vi.mock("@/lib/db", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/db")>()),
|
|
db: {
|
|
select: vi.fn(() => {
|
|
throw new Error("alert must not query DB");
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
|
logAudit: audit,
|
|
}));
|
|
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
|
rcon: { alertUser },
|
|
}));
|
|
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
|
|
|
import { peopleMutationService } from "./mutations";
|
|
|
|
function context(): HousekeepingCapabilityContext {
|
|
return {
|
|
actor: { id: 42, username: "operator", rank: 6 },
|
|
isSuperAdmin: false,
|
|
has: (slug) => slug === PERMS.USERS_EDIT,
|
|
hasAny: (...slugs) => slugs.includes(PERMS.USERS_EDIT),
|
|
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_EDIT),
|
|
};
|
|
}
|
|
|
|
const invocation = {
|
|
correlationId: "audit-contract",
|
|
expectedActorId: 42,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resolveServerContext.mockResolvedValue(context());
|
|
alertUser.mockResolvedValue(true);
|
|
audit.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("People external audit contract", () => {
|
|
it("blocks the external operation when intent persistence fails", async () => {
|
|
audit.mockRejectedValueOnce(new Error("intent unavailable"));
|
|
|
|
const result = await peopleMutationService.execute(
|
|
invocation,
|
|
"user.alert",
|
|
{ userId: 7, message: "Hello" },
|
|
);
|
|
|
|
expect(result).toMatchObject({ ok: false });
|
|
expect(alertUser).not.toHaveBeenCalled();
|
|
expect(audit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ outcome: "intent" }),
|
|
);
|
|
});
|
|
|
|
it("persists a correlated failure outcome when a pre-mutation external operation fails", async () => {
|
|
alertUser.mockResolvedValue(false);
|
|
|
|
const result = await peopleMutationService.execute(
|
|
invocation,
|
|
"user.alert",
|
|
{ userId: 7, message: "Hello" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
expect(
|
|
audit.mock.calls.map((call) => {
|
|
const entry = call[0] as AuditEntry;
|
|
return {
|
|
outcome: entry.outcome,
|
|
correlationId: entry.correlationId,
|
|
};
|
|
}),
|
|
).toEqual([
|
|
{ outcome: "intent", correlationId: "audit-contract" },
|
|
{ outcome: "failure", correlationId: "audit-contract" },
|
|
]);
|
|
});
|
|
|
|
it("returns serializable partial completion when final outcome persistence fails", async () => {
|
|
audit.mockImplementation(async (entry: AuditEntry) => {
|
|
if (entry.outcome === "success") throw new Error("outcome unavailable");
|
|
});
|
|
|
|
const result = await peopleMutationService.execute(
|
|
invocation,
|
|
"user.alert",
|
|
{ userId: 7, message: "Hello" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
completion: {
|
|
status: "partial",
|
|
external: "completed",
|
|
audit: "persisted",
|
|
},
|
|
data: {
|
|
before: null,
|
|
after: { userId: 7, alertDelivered: true },
|
|
},
|
|
correlationId: "audit-contract",
|
|
});
|
|
expect(() => JSON.stringify(result)).not.toThrow();
|
|
expect(alertUser).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
|
).toEqual(["intent", "success", "partial"]);
|
|
});
|
|
|
|
it("attempts legacy alert without a target lookup when the database is unavailable", async () => {
|
|
const result = await peopleMutationService.execute(
|
|
{ ...invocation, legacy: true },
|
|
"user.alert",
|
|
{ userId: 900719925, message: "Direct RCON" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
before: null,
|
|
after: { userId: 900719925, alertDelivered: true },
|
|
},
|
|
});
|
|
expect(alertUser).toHaveBeenCalledWith(900719925, "Direct RCON");
|
|
});
|
|
});
|