fix(housekeeping): harden people account workflows

This commit is contained in:
Simo committed 2026-08-29 13:13:04 +02:00
1 parent e1b31ff773
commit 25b76437ff
41 files changed
+2657 -838

No files matched your search

@@ -0,0 +1,143 @@
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, selectLimit } = vi.hoisted(
() => ({
alertUser: vi.fn(),
audit: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: 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) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return {
...actual,
db: {
...actual.db,
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: selectLimit })),
})),
})),
},
};
});
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());
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
rank: 2,
motto: "Ready",
credits: 100,
pixels: 50,
online: "1",
},
]);
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 the 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("throws typed completed-operation evidence when final outcome persistence fails", async () => {
audit.mockImplementation(async (entry: AuditEntry) => {
if (entry.outcome === "success") throw new Error("outcome unavailable");
});
await expect(
peopleMutationService.execute(invocation, "user.alert", {
userId: 7,
message: "Hello",
}),
).rejects.toMatchObject({
name: "AuditOutcomePersistenceError",
operationCompleted: true,
operationResult: {
before: expect.objectContaining({ id: 7 }),
after: expect.objectContaining({ alertDelivered: true }),
},
});
expect(alertUser).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,286 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const mocks = vi.hoisted(() => ({
audit: vi.fn(),
deleteWhere: vi.fn(),
insertValues: vi.fn(),
logStaffActivity: vi.fn(),
rcon: {
alertUser: vi.fn(),
disconnectUser: vi.fn(),
giveBadge: vi.fn(),
giveCredits: vi.fn(),
giveDuckets: vi.fn(),
givePointsGotw: vi.fn(),
setTradeLock: vi.fn(),
updateWordFilter: vi.fn(),
},
reloadSettings: vi.fn(),
reloadWordFilter: vi.fn(),
resolveServerContext: vi.fn(),
selectQueue: [] as unknown[][],
transaction: vi.fn(),
updateWhere: vi.fn(),
}));
function mutationPromise(
result: unknown = [{ insertId: 12, affectedRows: 2 }],
) {
return Object.assign(Promise.resolve(result), {
onDuplicateKeyUpdate: vi.fn(async () => result),
});
}
function selectResult() {
const value = mocks.selectQueue.shift() ?? [];
return Object.assign(Promise.resolve(value), {
limit: vi.fn(async () => value),
orderBy: vi.fn(() =>
Object.assign(Promise.resolve(value), {
limit: vi.fn(async () => value),
}),
),
});
}
function databaseFacade() {
const facade = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(selectResult),
})),
})),
insert: vi.fn(() => ({
values: mocks.insertValues.mockImplementation(() => mutationPromise()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]),
})),
})),
delete: vi.fn(() => ({
where: mocks.deleteWhere.mockResolvedValue([{ affectedRows: 2 }]),
})),
};
return facade;
}
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: mocks.resolveServerContext,
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
const tx = databaseFacade();
const root = databaseFacade();
return {
...actual,
db: {
...actual.db,
...root,
transaction: mocks.transaction.mockImplementation(async (callback) =>
callback(tx),
),
},
};
});
vi.mock("@/lib/services/audit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/audit")>()),
logAudit: mocks.audit,
}));
vi.mock("@/lib/services/moderation", () => ({
reloadWordFilter: mocks.reloadWordFilter,
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon }));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: {
get: vi.fn(
async (key: string) =>
({
vpn_block_enabled: "0",
vpn_provider: "none",
vpn_api_key: "stored-secret",
vpn_block_message: "Blocked",
})[key],
),
reload: mocks.reloadSettings,
},
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: mocks.logStaffActivity,
}));
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: () => true,
hasAny: () => true,
hasAll: () => true,
};
}
const invocation = {
correlationId: "production-workflow",
expectedActorId: 42,
legacy: true,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.selectQueue.length = 0;
mocks.resolveServerContext.mockResolvedValue(context());
mocks.audit.mockResolvedValue(undefined);
mocks.reloadSettings.mockResolvedValue(undefined);
for (const call of Object.values(mocks.rcon)) call.mockResolvedValue(true);
});
describe("People production workflow adapter", () => {
it("preserves legacy bulk order, duplicates, totals, and StaffActivities", async () => {
const ids = Array.from({ length: 101 }, (_, index) => (index % 2) + 1);
const result = await peopleMutationService.execute(
invocation,
"users.bulk-unban",
{ userIds: ids },
);
expect(result).toMatchObject({
ok: true,
data: { before: { userIds: ids }, after: { completed: 2, total: 101 } },
});
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bulk_unban",
description: "Unbanned 2 user(s)",
}),
);
});
it("uses observed trade-lock state, no target hierarchy, RCON, and legacy activity", async () => {
mocks.selectQueue.push(
[
{
id: 7,
username: "Alice",
rank: 7,
motto: "",
credits: 0,
pixels: 0,
online: "1",
},
],
[{ id: 3, tradeLockedUntil: 100, reason: "existing" }],
[{ canTrade: "0", tradelockAmount: 4 }],
);
const result = await peopleMutationService.execute(
invocation,
"user.trade-lock",
{ userId: 7, untilUnix: 200 },
);
expect(result).toMatchObject({
ok: true,
data: {
before: { tradeLockedUntil: 100, canTrade: "0", tradelockAmount: 4 },
after: { tradeLockedUntil: 200, canTrade: "0", tradelockAmount: 5 },
},
});
expect(mocks.rcon.setTradeLock).toHaveBeenCalledWith(7, true);
expect(mocks.rcon.disconnectUser).toHaveBeenCalledWith(7, "Alice");
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "trade_lock", targetId: 7 }),
);
});
it("runs guild, application, team, and IP database workflows transactionally", async () => {
mocks.selectQueue.push([{ id: 9, name: "Builders", userId: 7 }], []);
await expect(
peopleMutationService.execute(invocation, "guild.disband", {
guildId: 9,
}),
).resolves.toMatchObject({ ok: true });
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "guild_disband", targetId: 9 }),
);
mocks.selectQueue.push([
{ id: 5n, userId: 7, rankId: 4, content: "Ready" },
]);
await expect(
peopleMutationService.execute(invocation, "application.decide", {
applicationId: 5,
decision: "dismiss",
}),
).resolves.toMatchObject({ ok: true });
mocks.selectQueue.push([
{
id: 4n,
rankName: "MOD",
badge: null,
jobDescription: null,
hiddenRank: false,
},
]);
await expect(
peopleMutationService.execute(invocation, "team.change", {
action: "delete",
teamId: 4,
}),
).resolves.toMatchObject({ ok: true });
mocks.selectQueue.push([{ id: 3n, ipAddress: "198.51.100.3", asn: null }]);
await expect(
peopleMutationService.execute(invocation, "ip.action", {
action: "delete-blacklist",
id: 3,
}),
).resolves.toMatchObject({ ok: true });
expect(
mocks.audit.mock.calls.filter((call) => call[1]).length,
).toBeGreaterThanOrEqual(4);
});
it("keeps VPN secrets out of audit while reloading settings and logging legacy activity", async () => {
const result = await peopleMutationService.execute(
invocation,
"vpn.configure",
{
enabled: true,
provider: "proxycheck",
apiKey: "new-secret",
blockMessage: "No VPN",
},
);
expect(result).toMatchObject({ ok: true });
expect(mocks.reloadSettings).toHaveBeenCalledTimes(1);
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "vpn_update" }),
);
const serialized = JSON.stringify(
mocks.audit.mock.calls.map((call) => call[0] as AuditEntry),
);
expect(serialized).not.toContain("new-secret");
expect(serialized).not.toContain("stored-secret");
});
it("refreshes local and RCON wordfilter state when delete target is already missing", async () => {
mocks.selectQueue.push([]);
const result = await peopleMutationService.execute(
invocation,
"word-filter.update",
{ action: "delete", id: 99 },
);
expect(result).toMatchObject({
ok: true,
data: { before: null, after: null },
});
expect(mocks.reloadWordFilter).toHaveBeenCalledTimes(1);
expect(mocks.rcon.updateWordFilter).toHaveBeenCalledTimes(1);
});
});
@@ -2,12 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({
audit: vi.fn(),
alertUser: vi.fn(),
selectLimit: vi.fn(),
}));
const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
audit: vi.fn(),
alertUser: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: 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) => {
@@ -62,6 +68,7 @@ const target = {
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext([]));
selectLimit.mockResolvedValue([target]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
@@ -70,7 +77,7 @@ beforeEach(() => {
describe("People production mutation boundary", () => {
it("rechecks authorization before any production adapter work", async () => {
const result = await peopleMutationService.execute(
{ capability: capabilityContext([]), correlationId: "production-denied" },
{ expectedActorId: 42, correlationId: "production-denied" },
"user.alert",
{ userId: 7, message: "Hello" },
);
@@ -81,11 +88,11 @@ describe("People production mutation boundary", () => {
});
it("emits sanitized before and after evidence for a successful mutation", async () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-alert",
},
{ expectedActorId: 42, correlationId: "production-alert" },
"user.alert",
{ userId: 7, message: "Hello" },
);
@@ -107,17 +114,17 @@ describe("People production mutation boundary", () => {
);
});
it("fails closed when required audit evidence cannot be persisted", async () => {
it("blocks the external action when required intent evidence cannot be persisted", async () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
audit.mockRejectedValue(new Error("audit database unavailable"));
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-audit-failure",
},
{ expectedActorId: 42, correlationId: "production-audit-failure" },
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(alertUser).toHaveBeenCalled();
expect(alertUser).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
@@ -2,17 +2,39 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, disconnectUser, insertValues, selectLimit } = vi.hoisted(() => ({
const {
audit,
disconnectUser,
insertValues,
resolveServerContext,
selectLimit,
transaction,
txSelectLimit,
} = vi.hoisted(() => ({
audit: vi.fn(),
disconnectUser: vi.fn(),
insertValues: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: vi.fn(),
transaction: vi.fn(),
txSelectLimit: 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) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
};
return {
...actual,
db: {
@@ -22,7 +44,9 @@ vi.mock("@/lib/db", async (importOriginal) => {
where: vi.fn(() => ({ limit: selectLimit })),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
transaction: transaction.mockImplementation(async (callback) =>
callback(tx),
),
},
};
});
@@ -34,30 +58,28 @@ vi.mock("@/lib/services/rcon", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
rcon: { disconnectUser },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
import { peopleMutationService } from "./mutations";
function capabilityContext(
granted: readonly string[],
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
function capabilityContext(): HousekeepingCapabilityContext {
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)),
has: (slug) => slug === PERMS.USERS_BAN,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_BAN),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_BAN),
};
}
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext());
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
mail: "[email protected]",
rank: 2,
motto: "Ready",
credits: 100,
@@ -65,18 +87,16 @@ beforeEach(() => {
online: "1",
},
]);
txSelectLimit.mockResolvedValue([]);
insertValues.mockResolvedValue([{}]);
disconnectUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
});
describe("People production reason audit", () => {
it("persists the nonblank sanction reason with before and after evidence", async () => {
it("persists the nonblank sanction reason with observed before and after evidence", async () => {
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_BAN]),
correlationId: "production-ban",
},
{ expectedActorId: 42, correlationId: "production-ban" },
"user.ban",
{
userId: 7,
@@ -87,9 +107,11 @@ describe("People production reason audit", () => {
);
expect(result).toMatchObject({ ok: true });
expect(transaction).toHaveBeenCalledTimes(1);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
action: "people.user.ban",
outcome: "intent",
before: expect.objectContaining({ id: 7, banned: false }),
after: expect.objectContaining({
id: 7,
@@ -97,6 +119,7 @@ describe("People production reason audit", () => {
reason: "Repeated harassment",
}),
}),
expect.anything(),
);
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail");
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { alertUser, audit, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
alertUser: vi.fn(),
audit: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: 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) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return {
...actual,
db: {
...actual.db,
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: selectLimit })),
})),
})),
},
};
});
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(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)),
};
}
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(context([]));
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
rank: 2,
motto: "Ready",
credits: 100,
pixels: 50,
online: "1",
},
]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
});
describe("People production server authority", () => {
it("rejects a caller-forged capability and resolves the authenticated request actor", async () => {
const result = await peopleMutationService.execute(
{
correlationId: "server-authority",
expectedActorId: 42,
capability: context([PERMS.USERS_EDIT]),
} as never,
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(resolveServerContext).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
correlationId: "server-authority",
});
expect(selectLimit).not.toHaveBeenCalled();
expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const {
audit,
globalDeleteWhere,
resolveServerContext,
transaction,
txDeleteWhere,
txSelectLimit,
} = vi.hoisted(() => ({
audit: vi.fn(),
globalDeleteWhere: vi.fn(),
resolveServerContext: vi.fn(),
transaction: vi.fn(),
txDeleteWhere: vi.fn(),
txSelectLimit: 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) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return {
...actual,
db: {
...actual.db,
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })),
})),
})),
delete: vi.fn(() => ({ where: globalDeleteWhere })),
transaction,
},
};
});
vi.mock("@/lib/services/audit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/audit")>()),
logAudit: audit,
}));
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),
};
}
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(context());
txSelectLimit.mockResolvedValue([
{ id: 9n, userId: 7, rankId: 3, content: "Application" },
]);
globalDeleteWhere.mockResolvedValue(undefined);
txDeleteWhere.mockResolvedValue(undefined);
transaction.mockImplementation(async (callback) => {
let staged = false;
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(async (...args) => {
staged = true;
return txDeleteWhere(...args);
}),
})),
insert: vi.fn(),
};
try {
const value = await callback(tx);
return { value, committed: staged };
} catch (error) {
throw Object.assign(error as Error, { rolledBack: staged });
}
});
audit.mockRejectedValue(new Error("audit unavailable"));
});
describe("People transactional database audit", () => {
it("rolls back application deletion when canonical audit persistence fails", async () => {
const result = await peopleMutationService.execute(
{ correlationId: "transactional-audit", expectedActorId: 42 },
"application.decide",
{ applicationId: 9, decision: "dismiss" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(globalDeleteWhere).not.toHaveBeenCalled();
expect(txDeleteWhere).toHaveBeenCalledTimes(1);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
outcome: "success",
before: expect.objectContaining({ id: 9 }),
after: undefined,
}),
expect.objectContaining({ insert: expect.any(Function) }),
);
});
});
File diff suppressed because it is too large. Load diff