fix(housekeeping): harden people account workflows
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
+286
@@ -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" },
|
||||
|
||||
+38
-15
@@ -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();
|
||||
});
|
||||
});
|
||||
+118
@@ -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
Reference in new issue
Block a user