From 8a894617e790bbdff38768ee1b71f543adb8cd93 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 11:17:19 +0200 Subject: [PATCH] fix(housekeeping): unify moderation target authority --- src/actions/bulk-users.test.ts | 3 + src/actions/bulk-users.ts | 100 ++---- src/actions/moderation-authority.test.ts | 212 ++++++++++++ src/actions/moderation.ts | 91 +++-- src/actions/set-trade-lock.test.ts | 19 +- src/actions/users.ts | 18 +- .../people/services/moderation-mutations.ts | 128 +++++-- .../services/mutations-audit-contract.test.ts | 52 ++- .../mutations-production-workflows.test.ts | 321 +++++++++++++++++- .../services/mutations-production.test.ts | 18 +- .../domains/people/services/mutations.ts | 142 ++++++-- src/lib/staff-smoke-contract.test.ts | 5 +- 12 files changed, 873 insertions(+), 236 deletions(-) create mode 100644 src/actions/moderation-authority.test.ts diff --git a/src/actions/bulk-users.test.ts b/src/actions/bulk-users.test.ts index 22f2124b..e2907eef 100644 --- a/src/actions/bulk-users.test.ts +++ b/src/actions/bulk-users.test.ts @@ -41,6 +41,9 @@ const { }); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + peopleMutationService: { execute: vi.fn() }, +})); vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); vi.mock("@/lib/db", () => ({ db: { diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index a6a3d057..dca2d4ad 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -1,16 +1,10 @@ "use server"; +import crypto from "node:crypto"; import { and, eq, inArray, max, sql } from "drizzle-orm"; +import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { - Ban, - db, - Sanctions, - User, - UsersBadges, - UsersCurrency, - UsersSettings, -} from "@/lib/db"; +import { Ban, db, User, UsersBadges, UsersCurrency } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; import { rcon } from "@/lib/services/rcon"; @@ -302,74 +296,28 @@ export async function setTradeLock({ }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const until = Math.max(0, Math.trunc(untilUnix)); - const locked = until > 0; - - const [user] = await db - .select({ - id: User.id, - username: User.username, - online: User.online, - }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - if (!user) { - return { ok: false as const, error: "User not found" }; - } - - await db.transaction(async (tx) => { - const [existing] = await tx - .select({ id: Sanctions.id }) - .from(Sanctions) - .where(eq(Sanctions.habboId, userId)) - .limit(1); - if (existing) { - await tx - .update(Sanctions) - .set({ - tradeLockedUntil: until, - ...(locked ? { reason: "Trade lock (CMS)" } : {}), - }) - .where(eq(Sanctions.id, existing.id)); - } else { - await tx.insert(Sanctions).values({ - habboId: userId, - tradeLockedUntil: until, - reason: locked ? "Trade lock (CMS)" : "", - }); - } - - await tx - .update(UsersSettings) - .set({ - canTrade: locked ? "0" : "1", - ...(locked - ? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` } - : {}), - }) - .where(eq(UsersSettings.userId, userId)); - }); - - await rcon.setTradeLock(userId, locked); - await rcon.alertUser( - userId, - locked - ? "Trading has been disabled by staff." - : "Trading has been re-enabled by staff.", + const result = await peopleMutationService.execute( + { + correlationId: crypto.randomUUID(), + expectedActorId: staff.id, + }, + "user.trade-lock", + { userId, untilUnix: until }, ); - if (user.online === "1") { - await rcon.disconnectUser(userId, user.username); + if (!result.ok) { + return { + ok: false as const, + error: + result.error.code === "NOT_FOUND" + ? "User not found" + : result.error.messageKey, + }; + } + if (result.completion?.external === "failed") { + return { + ok: false as const, + error: `Trade lock saved; synchronization is pending. Reference: ${result.correlationId}`, + }; } - - await logStaffActivity({ - staffId: staff.id, - action: locked ? "trade_lock" : "trade_unlock", - description: locked - ? `Trade-locked ${user.username} (#${userId}) until ${until}` - : `Cleared trade lock for ${user.username} (#${userId})`, - targetType: "user", - targetId: userId, - }); - return { ok: true as const, data: { userId, untilUnix: until } }; } diff --git a/src/actions/moderation-authority.test.ts b/src/actions/moderation-authority.test.ts new file mode 100644 index 00000000..7e7de8fe --- /dev/null +++ b/src/actions/moderation-authority.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + requirePermission: vi.fn(), + rcon: { + alertUser: vi.fn(), + disconnectUser: vi.fn(), + muteUser: vi.fn(), + unmuteUser: vi.fn(), + kickAll: vi.fn(), + hotelAlert: vi.fn(), + staffAlert: vi.fn(), + }, +})); + +function actionWrapper( + _options: unknown, + handler: (context: { + data: Record; + session: { user: { id: number; username: string; rank: number } }; + permissions: { isSuperAdmin: boolean }; + }) => Promise, +) { + return async (data: Record) => { + try { + return await handler({ + data, + session: { user: { id: 42, username: "operator", rank: 6 } }, + permissions: { isSuperAdmin: false }, + }); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Internal server error", + }; + } + }; +} + +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + peopleMutationService: { execute: mocks.execute }, +})); +vi.mock("@/lib/admin/guard", () => ({ + requirePermission: mocks.requirePermission, +})); +vi.mock("@/lib/foundation/action", () => ({ + actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }), + adminAction: actionWrapper, +})); +vi.mock("@/lib/safe-action", () => ({ adminAction: actionWrapper })); +vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); +vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon })); +vi.mock("@/lib/services/staff-activity", () => ({ + logStaffActivity: vi.fn(), +})); +vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() })); +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(); + return { + ...actual, + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: vi.fn(async () => []) })), + })), + })), + }, + }; +}); + +import { setTradeLock } from "./bulk-users"; +import { quickAlert, quickKick, quickMute, quickUnmute } from "./moderation"; +import { alertUser } from "./users"; + +const success = { + ok: true as const, + data: { before: null, after: null }, + correlationId: "legacy-authority", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.execute.mockResolvedValue(success); + mocks.requirePermission.mockResolvedValue({ id: 42 }); + for (const method of Object.values(mocks.rcon)) + method.mockResolvedValue(false); +}); + +describe("legacy moderation authority delegation", () => { + it.each([ + [quickKick, { userId: 7 }, { action: "kick", userId: 7 }], + [ + quickMute, + { userId: 7, duration: 60 }, + { action: "mute", userId: 7, duration: 60 }, + ], + [quickUnmute, { userId: 7 }, { action: "unmute", userId: 7 }], + [ + quickAlert, + { userId: 7, message: "Stop" }, + { action: "alert", userId: 7, message: "Stop" }, + ], + ] as const)( + "routes a quick user action through the strict canonical service", + async (action, input, canonicalInput) => { + await expect(action(input as never)).resolves.toMatchObject({ ok: true }); + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ expectedActorId: 42 }), + "moderation.action", + canonicalInput, + ); + expect(mocks.execute.mock.calls[0]?.[0]).not.toHaveProperty( + "legacy", + true, + ); + }, + ); + + it("does not report quick-action transport failure as success", async () => { + mocks.execute.mockResolvedValue({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + correlationId: "quick-failed", + }); + + await expect(quickKick({ userId: 7 })).resolves.toEqual({ + ok: false, + error: "errors.housekeeping.dependencyUnavailable", + }); + }); + + it("routes the legacy user alert through canonical target authority", async () => { + await expect( + alertUser({ userId: 7, message: "Stop" }), + ).resolves.toMatchObject({ + ok: true, + }); + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ expectedActorId: 42 }), + "user.alert", + { userId: 7, message: "Stop" }, + ); + }); + + it("preserves the legacy user-alert dependency failure response", async () => { + mocks.execute.mockResolvedValue({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + correlationId: "alert-failed", + }); + + await expect(alertUser({ userId: 7, message: "Stop" })).resolves.toEqual({ + ok: false, + error: "Failed to send alert. Is the emulator running?", + }); + }); + + it("preserves the legacy trade-lock not-found response", async () => { + mocks.execute.mockResolvedValue({ + ok: false, + error: { + code: "NOT_FOUND", + messageKey: "errors.housekeeping.notFound", + }, + correlationId: "trade-missing", + }); + + await expect(setTradeLock({ userId: 7, untilUnix: 200 })).resolves.toEqual({ + ok: false, + error: "User not found", + }); + }); + + it("returns a committed-sync warning for a partial legacy trade lock", async () => { + mocks.execute.mockResolvedValue({ + ok: true, + data: { + before: null, + after: null, + output: { userId: 7, untilUnix: 200 }, + }, + completion: { + status: "partial", + external: "failed", + audit: "persisted", + }, + correlationId: "trade-partial", + }); + + const result = await setTradeLock({ userId: 7, untilUnix: 200 }); + + expect(result).toEqual({ + ok: false, + error: + "Trade lock saved; synchronization is pending. Reference: trade-partial", + }); + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ expectedActorId: 42 }), + "user.trade-lock", + { userId: 7, untilUnix: 200 }, + ); + }); +}); diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts index 4a2ec30a..6c6b9ca0 100644 --- a/src/actions/moderation.ts +++ b/src/actions/moderation.ts @@ -1,11 +1,14 @@ "use server"; +import crypto from "node:crypto"; import { eq } from "drizzle-orm"; import { z } from "zod"; +import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations"; import { db, SupportTickets } from "@/lib/db"; import { actionOk, adminAction } from "@/lib/foundation/action"; import { NotFoundError } from "@/lib/foundation/errors"; import { PERMS } from "@/lib/permissions"; +import { ActionError } from "@/lib/safe-action-shared"; import { logAudit } from "@/lib/services/audit"; import { rcon } from "@/lib/services/rcon"; @@ -101,20 +104,29 @@ export const closeCfhTicket = adminAction( const userIdSchema = z.object({ userId: z.coerce.number().int().positive() }); +async function runGuardedQuickAction( + actorId: number, + input: + | { action: "kick" | "unmute"; userId: number } + | { action: "mute"; userId: number; duration: number } + | { action: "alert"; userId: number; message: string }, +) { + const result = await peopleMutationService.execute( + { correlationId: crypto.randomUUID(), expectedActorId: actorId }, + "moderation.action", + input, + ); + if (!result.ok) throw new ActionError(result.error.messageKey); + return actionOk(); +} + export const quickKick = adminAction( { permission: MOD_ACTION_PERM, schema: userIdSchema }, - async (ctx) => { - await rcon.disconnectUser(ctx.data.userId); - - logAudit({ - userId: ctx.session.user.id, - action: "mod_kick", - target: "User", - targetId: ctx.data.userId, - }); - - return actionOk(); - }, + (ctx) => + runGuardedQuickAction(Number(ctx.session.user.id), { + action: "kick", + userId: ctx.data.userId, + }), ); const muteSchema = z.object({ @@ -124,35 +136,21 @@ const muteSchema = z.object({ export const quickMute = adminAction( { permission: MOD_ACTION_PERM, schema: muteSchema }, - async (ctx) => { - await rcon.muteUser(ctx.data.userId, ctx.data.duration); - - logAudit({ - userId: ctx.session.user.id, - action: "mod_mute", - target: "User", - targetId: ctx.data.userId, - after: { duration: ctx.data.duration }, - }); - - return actionOk(); - }, + (ctx) => + runGuardedQuickAction(Number(ctx.session.user.id), { + action: "mute", + userId: ctx.data.userId, + duration: ctx.data.duration, + }), ); export const quickUnmute = adminAction( { permission: MOD_ACTION_PERM, schema: userIdSchema }, - async (ctx) => { - await rcon.unmuteUser(ctx.data.userId); - - logAudit({ - userId: ctx.session.user.id, - action: "mod_unmute", - target: "User", - targetId: ctx.data.userId, - }); - - return actionOk(); - }, + (ctx) => + runGuardedQuickAction(Number(ctx.session.user.id), { + action: "unmute", + userId: ctx.data.userId, + }), ); const alertSchema = z.object({ @@ -162,19 +160,12 @@ const alertSchema = z.object({ export const quickAlert = adminAction( { permission: MOD_ACTION_PERM, schema: alertSchema }, - async (ctx) => { - await rcon.alertUser(ctx.data.userId, ctx.data.message); - - logAudit({ - userId: ctx.session.user.id, - action: "mod_alert", - target: "User", - targetId: ctx.data.userId, - after: { message: ctx.data.message }, - }); - - return actionOk(); - }, + (ctx) => + runGuardedQuickAction(Number(ctx.session.user.id), { + action: "alert", + userId: ctx.data.userId, + message: ctx.data.message, + }), ); const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() }); diff --git a/src/actions/set-trade-lock.test.ts b/src/actions/set-trade-lock.test.ts index 6a4a2325..b92614d1 100644 --- a/src/actions/set-trade-lock.test.ts +++ b/src/actions/set-trade-lock.test.ts @@ -4,25 +4,12 @@ import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; describe("setTradeLock drizzle + RCON contract", () => { const src = readFileSync("src/actions/bulk-users.ts", "utf8"); - const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8"); - it("writes sanctions + users_settings via Drizzle", () => { - expect(src).toContain("@/lib/db"); - expect(src).toContain("UsersSettings"); - expect(src).toContain("Sanctions"); - expect(src).toContain("canTrade"); - expect(src).toContain("tradeLockedUntil"); + it("delegates persistence and live sync to the guarded people service", () => { expect(src).toMatch(/export async function setTradeLock/); const fn = src.slice(src.indexOf("export async function setTradeLock")); - expect(fn).toContain("db."); - }); - - it("syncs live hotel via RCON settradelock + alert + disconnect", () => { - expect(rconSrc).toContain("settradelock"); - expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)"); - expect(src).toContain("rcon.setTradeLock"); - expect(src).toContain("rcon.alertUser"); - expect(src).toContain("rcon.disconnectUser"); + expect(fn).toContain("peopleMutationService.execute"); + expect(fn).toContain('"user.trade-lock"'); }); }); diff --git a/src/actions/users.ts b/src/actions/users.ts index e2191fdb..b68e78ae 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -378,9 +378,21 @@ const alertUserSchema = z.object({ export const alertUser = adminAction( { permission: PERMS.USERS_EDIT, schema: alertUserSchema }, async (ctx) => { - const success = await rcon.alertUser(ctx.data.userId, ctx.data.message); - if (!success) - throw new ActionError("Failed to send alert. Is the emulator running?"); + const result = await peopleMutationService.execute( + { + correlationId: crypto.randomUUID(), + expectedActorId: Number(ctx.session.user.id), + }, + "user.alert", + ctx.data, + ); + if (!result.ok) { + throw new ActionError( + result.error.code === "DEPENDENCY_UNAVAILABLE" + ? "Failed to send alert. Is the emulator running?" + : result.error.messageKey, + ); + } return actionOk(); }, ); diff --git a/src/features/housekeeping/domains/people/services/moderation-mutations.ts b/src/features/housekeeping/domains/people/services/moderation-mutations.ts index 77cfddfb..3790b7ef 100644 --- a/src/features/housekeeping/domains/people/services/moderation-mutations.ts +++ b/src/features/housekeeping/domains/people/services/moderation-mutations.ts @@ -1,7 +1,7 @@ import "server-only"; import { eq } from "drizzle-orm"; -import { Ban, type Db, SupportTickets, User } from "@/lib/db"; +import { Ban, type Db, SupportTickets } from "@/lib/db"; import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; import type { ModerationAction, @@ -62,6 +62,25 @@ export interface PeopleModerationMutationDependencies { required?: boolean, ) => string; readonly requireRcon: (result: boolean) => Promise; + readonly loadTarget: ( + userId: number, + context: PeopleModerationMutationContext, + guardHierarchy: boolean, + source?: Pick, + lock?: boolean, + ) => Promise<{ + readonly id: number; + readonly username: string; + readonly rank: number; + readonly ipCurrent: string; + readonly machineId: string; + readonly online: string; + }>; + readonly runWithLockedTargetAuthority: ( + userId: number, + context: PeopleModerationMutationContext, + execute: () => Promise, + ) => Promise; readonly transport: ModerationTransport; readonly executeModerationAction: ( transport: ModerationActionTransport, @@ -124,6 +143,8 @@ export function createPeopleModerationMutationExecutor( nonNegativeInteger, normalizedText, requireRcon, + loadTarget, + runWithLockedTargetAuthority, transport, executeModerationAction, logStaffActivity, @@ -194,6 +215,35 @@ export function createPeopleModerationMutationExecutor( before: null, after: { ...action }, } satisfies PeopleModerationMutationSnapshot; + if ("userId" in action) { + await db.transaction(async (tx) => { + await loadTarget(action.userId, context, true, tx, true); + await writeAudit( + auditEntry( + context, + "moderation.action", + "User", + action.userId, + snapshot, + "intent", + ), + tx, + ); + }); + return finalizeExternalWithAudit( + context, + "moderation.action", + "User", + action.userId, + snapshot, + () => + runWithLockedTargetAuthority(action.userId, context, () => + deliverModerationAction(action, context), + ), + false, + { before: null, after: null }, + ); + } return runExternalWithAudit( context, "moderation.action", @@ -211,6 +261,10 @@ export function createPeopleModerationMutationExecutor( ): Promise { const data = record(input); const ticketId = positiveInteger(data.ticketId); + const requestedAction = normalizedText(data.action, 32); + if (!["kick", "mute", "alert"].includes(requestedAction)) { + throw failure("VALIDATION", "errors.housekeeping.validation"); + } const action = moderationAction({ ...data, message: data.message ?? data.reason, @@ -223,13 +277,21 @@ export function createPeopleModerationMutationExecutor( id: SupportTickets.id, state: SupportTickets.state, modId: SupportTickets.modId, + reportedId: SupportTickets.reportedId, }) .from(SupportTickets) .where(eq(SupportTickets.id, ticketId)) - .limit(1); + .for("update"); if (!ticket) { throw failure("NOT_FOUND", "errors.housekeeping.notFound"); } + if (!("userId" in action) || action.userId !== ticket.reportedId) { + throw failure("FORBIDDEN", "errors.housekeeping.forbidden"); + } + if (ticket.state !== 0 && ticket.state !== 1) { + throw failure("CONFLICT", "errors.housekeeping.conflict"); + } + await loadTarget(ticket.reportedId, context, true, tx, true); snapshot = { before: { id: ticket.id, @@ -266,7 +328,14 @@ export function createPeopleModerationMutationExecutor( "support_tickets", ticketId, snapshot, - () => deliverModerationAction(action, context), + () => { + if (!("userId" in action)) { + throw failure("VALIDATION", "errors.housekeeping.validation"); + } + return runWithLockedTargetAuthority(action.userId, context, () => + deliverModerationAction(action, context), + ); + }, ); } @@ -293,16 +362,20 @@ export function createPeopleModerationMutationExecutor( let banId = 0; let snapshot!: PeopleModerationMutationSnapshot; await db.transaction(async (tx) => { - const [user] = await tx - .select({ username: User.username }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - username = user?.username ?? null; + const user = await loadTarget(userId, context, true, tx, true); + username = user.username; + const needsIp = type === "ip" || type === "super"; + const needsMachine = type === "machine" || type === "super"; + if ( + (needsIp && user.ipCurrent.trim().length === 0) || + (needsMachine && user.machineId.trim().length === 0) + ) { + throw failure("VALIDATION", "errors.housekeeping.validation"); + } const [result] = await tx.insert(Ban).values({ userId, - ip: "", - machineId: "", + ip: needsIp ? user.ipCurrent : "", + machineId: needsMachine ? user.machineId : "", userStaffId: context.capability.actor.id, timestamp: now, banExpire, @@ -332,22 +405,23 @@ export function createPeopleModerationMutationExecutor( "Ban", banId, snapshot, - async () => { - let delivered = true; - if (username !== null) { - delivered = await transport.disconnectUser(userId, username); - } - await logStaffActivity({ - staffId: context.capability.actor.id, - action: "user_ban", - description: `Banned user #${userId} (${type}, ${ - hours > 0 ? `${hours}h` : "permanent" - }): ${reason}`, - targetType: "user", - targetId: userId, - }); - await requireRcon(delivered); - }, + () => + runWithLockedTargetAuthority(userId, context, async () => { + let delivered = true; + if (username !== null) { + delivered = await transport.disconnectUser(userId, username); + } + await logStaffActivity({ + staffId: context.capability.actor.id, + action: "user_ban", + description: `Banned user #${userId} (${type}, ${ + hours > 0 ? `${hours}h` : "permanent" + }): ${reason}`, + targetType: "user", + targetId: userId, + }); + await requireRcon(delivered); + }), ); } diff --git a/src/features/housekeeping/domains/people/services/mutations-audit-contract.test.ts b/src/features/housekeeping/domains/people/services/mutations-audit-contract.test.ts index a8765021..871eb9f7 100644 --- a/src/features/housekeeping/domains/people/services/mutations-audit-contract.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-audit-contract.test.ts @@ -3,11 +3,14 @@ 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(), -})); +const { alertUser, audit, resolveServerContext, selectFor, transaction } = + vi.hoisted(() => ({ + alertUser: vi.fn(), + audit: vi.fn(), + resolveServerContext: vi.fn(), + selectFor: vi.fn(), + transaction: vi.fn(), + })); vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ getHousekeepingCapabilityContext: resolveServerContext, @@ -17,9 +20,7 @@ vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); vi.mock("@/lib/db", async (importOriginal) => ({ ...(await importOriginal()), db: { - select: vi.fn(() => { - throw new Error("alert must not query DB"); - }), + transaction, }, })); vi.mock("@/lib/services/audit", async (importOriginal) => ({ @@ -54,6 +55,28 @@ beforeEach(() => { resolveServerContext.mockResolvedValue(context()); alertUser.mockResolvedValue(true); audit.mockResolvedValue(undefined); + selectFor.mockResolvedValue([ + { + id: 7, + username: "Alice", + rank: 2, + ipCurrent: "203.0.113.7", + machineId: "machine-7", + motto: "Ready", + credits: 100, + pixels: 50, + online: "1", + }, + ]); + transaction.mockImplementation(async (callback) => + callback({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ for: selectFor })), + })), + })), + }), + ); }); describe("People external audit contract", () => { @@ -70,6 +93,7 @@ describe("People external audit contract", () => { expect(alertUser).not.toHaveBeenCalled(); expect(audit).toHaveBeenCalledWith( expect.objectContaining({ outcome: "intent" }), + expect.any(Object), ); }); @@ -131,7 +155,8 @@ describe("People external audit contract", () => { ).toEqual(["intent", "success", "partial"]); }); - it("attempts legacy alert without a target lookup when the database is unavailable", async () => { + it("fails a legacy alert closed when target authority cannot be loaded", async () => { + transaction.mockRejectedValueOnce(new Error("database unavailable")); const result = await peopleMutationService.execute( { ...invocation, legacy: true }, "user.alert", @@ -139,12 +164,9 @@ describe("People external audit contract", () => { ); expect(result).toMatchObject({ - ok: true, - data: { - before: null, - after: { userId: 900719925, alertDelivered: true }, - }, + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, }); - expect(alertUser).toHaveBeenCalledWith(900719925, "Direct RCON"); + expect(alertUser).not.toHaveBeenCalled(); }); }); diff --git a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts index b7acb383..5f0f9b6c 100644 --- a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts @@ -175,6 +175,276 @@ beforeEach(() => { }); describe("People production workflow adapter", () => { + it.each([ + ["user.alert", { userId: 7, message: "Stop" }, "alertUser"], + ["user.trade-lock", { userId: 7, untilUnix: 200 }, "setTradeLock"], + ["moderation.action", { action: "kick", userId: 7 }, "disconnectUser"], + [ + "ban.create", + { userId: 7, hours: 24, type: "account", reason: "Abuse" }, + "disconnectUser", + ], + ] as const)( + "denies peer targets before writes, audit, or transport for %s", + async (operation, input, transport) => { + mocks.selectQueue.push([target({ rank: 6 })]); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: `peer-${operation}` }, + operation, + input, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); + expect(mocks.insertValues).not.toHaveBeenCalled(); + expect(mocks.updateSet).not.toHaveBeenCalled(); + expect(mocks.audit).not.toHaveBeenCalled(); + expect(mocks.rcon[transport]).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["user.alert", { userId: 7, message: "Stop" }, "alertUser"], + ["user.trade-lock", { userId: 7, untilUnix: 200 }, "setTradeLock"], + ["moderation.action", { action: "kick", userId: 7 }, "disconnectUser"], + [ + "ban.create", + { userId: 7, hours: 24, type: "account", reason: "Abuse" }, + "disconnectUser", + ], + ] as const)( + "returns NOT_FOUND before writes or transport for absent %s targets", + async (operation, input, transport) => { + mocks.selectQueue.push([]); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: `absent-${operation}` }, + operation, + input, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + expect(mocks.insertValues).not.toHaveBeenCalled(); + expect(mocks.updateSet).not.toHaveBeenCalled(); + expect(mocks.audit).not.toHaveBeenCalled(); + expect(mocks.rcon[transport]).not.toHaveBeenCalled(); + }, + ); + + it("allows a super-admin to moderate a peer while retaining transactional intent audit", async () => { + mocks.resolveServerContext.mockResolvedValue({ + ...context(), + isSuperAdmin: true, + }); + mocks.selectQueue.push([target({ rank: 6 })], [target({ rank: 6 })]); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: "superadmin-alert" }, + "user.alert", + { userId: 7, message: "Stop" }, + ); + + expect(result).toMatchObject({ ok: true }); + expect(mocks.rcon.alertUser).toHaveBeenCalledWith(7, "Stop"); + expect(mocks.audit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ outcome: "intent" }), + expect.any(Object), + ); + }); + + it("rechecks user authority immediately before external moderation dispatch", async () => { + mocks.selectQueue.push([target({ rank: 2 })], [target({ rank: 7 })]); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: "promoted-before-rcon" }, + "moderation.action", + { action: "kick", userId: 7 }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); + expect(mocks.rcon.disconnectUser).not.toHaveBeenCalled(); + expect( + mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome), + ).toEqual(["intent", "failure"]); + }); + + it("denies a higher-ranked moderation target before intent or dispatch", async () => { + mocks.selectQueue.push([target({ rank: 7 })]); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: "higher-target" }, + "moderation.action", + { action: "kick", userId: 7 }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); + expect(mocks.audit).not.toHaveBeenCalled(); + expect(mocks.rcon.disconnectUser).not.toHaveBeenCalled(); + }); + + it("holds the user lock through dispatch and preserves a completed effect on commit failure", async () => { + let dispatchLockHeld = false; + mocks.selectQueue.push([target()], [target()]); + mocks.transaction + .mockImplementationOnce(async (callback) => callback(databaseFacade())) + .mockImplementationOnce(async (callback) => { + dispatchLockHeld = true; + await callback(databaseFacade()); + dispatchLockHeld = false; + throw new Error("read-only authority commit failed"); + }); + mocks.rcon.disconnectUser.mockImplementationOnce(async () => { + expect(dispatchLockHeld).toBe(true); + return true; + }); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: "dispatch-committed" }, + "moderation.action", + { action: "kick", userId: 7 }, + ); + + expect(result).toMatchObject({ + ok: true, + completion: { + status: "partial", + external: "completed", + audit: "persisted", + }, + }); + expect(mocks.rcon.disconnectUser).toHaveBeenCalledTimes(1); + expect( + mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome), + ).toEqual(["intent", "partial"]); + }); + + it("rejects forged, closed, and non-user CFH sanctions before mutation", async () => { + for (const [ticket, input, code] of [ + [ + { id: 9, state: 1, modId: 8, reportedId: 7 }, + { ticketId: 9, action: "alert", userId: 8, reason: "Abuse" }, + "FORBIDDEN", + ], + [ + { id: 9, state: 2, modId: 8, reportedId: 7 }, + { ticketId: 9, action: "alert", userId: 7, reason: "Abuse" }, + "CONFLICT", + ], + [ + { id: 9, state: 1, modId: 8, reportedId: 7 }, + { ticketId: 9, action: "room-kick", roomId: 4, reason: "Abuse" }, + "VALIDATION", + ], + ] as const) { + mocks.selectQueue.push([ticket]); + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: `cfh-${code}` }, + "cfh.sanction", + input, + ); + expect(result).toMatchObject({ ok: false, error: { code } }); + expect(mocks.updateSet).not.toHaveBeenCalled(); + expect(mocks.audit).not.toHaveBeenCalled(); + expect(mocks.rcon.alertUser).not.toHaveBeenCalled(); + expect(mocks.rcon.kickAll).not.toHaveBeenCalled(); + vi.clearAllMocks(); + mocks.resolveServerContext.mockResolvedValue(context()); + mocks.audit.mockResolvedValue(undefined); + } + }); + + it("binds a CFH sanction to an existing lower-ranked reported user", async () => { + mocks.selectQueue.push( + [{ id: 9, state: 1, modId: 8, reportedId: 7 }], + [target()], + [target()], + ); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: "cfh-authorized" }, + "cfh.sanction", + { ticketId: 9, action: "alert", userId: 7, reason: "Abuse" }, + ); + + expect(result).toMatchObject({ ok: true }); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ state: 2, modId: 42 }), + ); + expect(mocks.rcon.alertUser).toHaveBeenCalledWith(7, "Abuse"); + expect(mocks.audit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ outcome: "intent" }), + expect.any(Object), + ); + }); + + it("returns NOT_FOUND when a CFH reported user no longer exists", async () => { + mocks.selectQueue.push([{ id: 9, state: 1, modId: 8, reportedId: 7 }], []); + + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: "cfh-missing-user" }, + "cfh.sanction", + { ticketId: 9, action: "alert", userId: 7, reason: "Abuse" }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + expect(mocks.updateSet).not.toHaveBeenCalled(); + expect(mocks.audit).not.toHaveBeenCalled(); + expect(mocks.rcon.alertUser).not.toHaveBeenCalled(); + }); + + it.each([ + ["ip", "", "machine-7"], + ["machine", "203.0.113.7", ""], + ["super", "", "machine-7"], + ["super", "203.0.113.7", ""], + ] as const)( + "rejects %s bans when a required target identifier is missing", + async (type, ipCurrent, machineId) => { + mocks.selectQueue.push([target({ ipCurrent, machineId })]); + const result = await peopleMutationService.execute( + { ...invocation, legacy: false }, + "ban.create", + { userId: 7, hours: 24, type, reason: "Abuse" }, + ); + expect(result).toMatchObject({ + ok: false, + error: { code: "VALIDATION" }, + }); + expect(mocks.insertValues).not.toHaveBeenCalled(); + expect(mocks.rcon.disconnectUser).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["account", "", ""], + ["ip", "203.0.113.7", ""], + ["machine", "", "machine-7"], + ["super", "203.0.113.7", "machine-7"], + ] as const)( + "persists actual target identifiers for a successful %s ban", + async (type, ip, machineId) => { + mocks.selectQueue.push( + [target({ ipCurrent: "203.0.113.7", machineId: "machine-7" })], + [target({ ipCurrent: "203.0.113.7", machineId: "machine-7" })], + ); + const result = await peopleMutationService.execute( + { ...invocation, legacy: false, correlationId: `ban-${type}` }, + "ban.create", + { userId: 7, hours: 24, type, reason: "Abuse" }, + ); + expect(result).toMatchObject({ ok: true }); + expect(mocks.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ ip, machineId, type }), + ); + expect(mocks.audit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ outcome: "intent" }), + expect.any(Object), + ); + }, + ); it("preserves completed rank delivery when only the synchronization audit fails", async () => { mocks.selectQueue.push([target()], [target()], [target({ rank: 4 })]); mocks.audit.mockImplementation(async (entry) => { @@ -347,13 +617,13 @@ describe("People production workflow adapter", () => { ); }); - it("uses observed trade-lock state, no target hierarchy, RCON, and legacy activity", async () => { + it("uses observed trade-lock state, target hierarchy, RCON, and legacy activity", async () => { mocks.selectQueue.push( [ { id: 7, username: "Alice", - rank: 7, + rank: 2, motto: "", credits: 0, pixels: 0, @@ -362,6 +632,7 @@ describe("People production workflow adapter", () => { ], [{ id: 3, tradeLockedUntil: 100, reason: "existing" }], [{ canTrade: "0", tradelockAmount: 4 }], + [target()], ); const result = await peopleMutationService.execute( invocation, @@ -773,7 +1044,11 @@ describe("People production workflow adapter", () => { ] as const)( "audits confirmed external-only %s failure without optimistic state", async (operation, rconMethod, input, failureAfter) => { - if (operation !== "user.alert") mocks.selectQueue.push([target()]); + mocks.selectQueue.push( + ...(operation === "user.alert" + ? [[target()], [target()]] + : [[target()]]), + ); mocks.rcon[rconMethod].mockResolvedValueOnce(false); const correlationId = `confirmed-${operation}`; @@ -821,7 +1096,11 @@ describe("People production workflow adapter", () => { ] as const)( "audits unknown external-only %s delivery with a null after-state", async (operation, rconMethod, input) => { - if (operation !== "user.alert") mocks.selectQueue.push([target()]); + mocks.selectQueue.push( + ...(operation === "user.alert" + ? [[target()], [target()]] + : [[target()]]), + ); mocks.rcon[rconMethod].mockRejectedValueOnce( new Error("RCON unavailable"), ); @@ -1248,7 +1527,11 @@ describe("People production workflow adapter", () => { userId: 7, reason: "Repeated abuse", }, - rows: [[{ id: 9, state: 1, modId: 8 }]], + rows: [ + [{ id: 9, state: 1, modId: 8, reportedId: 7 }], + [target()], + [target()], + ], transactional: true, }, { @@ -1259,7 +1542,7 @@ describe("People production workflow adapter", () => { type: "account", reason: "Repeated abuse", }, - rows: [[{ username: "Alice" }]], + rows: [[target()], [target()]], transactional: true, }, { @@ -1273,7 +1556,7 @@ describe("People production workflow adapter", () => { { operation: "moderation.action", input: { action: "kick", userId: 7 }, - rows: [], + rows: [[target()], [target()]], transactional: false, }, ] as const)( @@ -1297,7 +1580,8 @@ describe("People production workflow adapter", () => { }), ...(transactional ? [expect.any(Object)] : []), ); - if (transactional) expect(mocks.transaction).toHaveBeenCalledTimes(1); + if (transactional) + expect(mocks.transaction.mock.calls.length).toBeGreaterThanOrEqual(1); }, ); @@ -1338,6 +1622,9 @@ describe("People production workflow adapter", () => { ] as const)( "preserves legacy confirmed-false success for %s while recording an observed outcome", async (_label, input, transport) => { + if ("userId" in input) { + mocks.selectQueue.push([target()], [target()]); + } mocks.rcon[transport].mockResolvedValueOnce(false); const result = await peopleMutationService.execute( { ...invocation, correlationId: `legacy-false-${transport}` }, @@ -1355,6 +1642,7 @@ describe("People production workflow adapter", () => { ); it("keeps Housekeeping false strict, propagates throws, and blocks delivery when intent audit fails", async () => { + mocks.selectQueue.push([target()], [target()]); mocks.rcon.disconnectUser.mockResolvedValueOnce(false); await expect( peopleMutationService.execute( @@ -1376,6 +1664,8 @@ describe("People production workflow adapter", () => { vi.clearAllMocks(); mocks.resolveServerContext.mockResolvedValue(context()); mocks.audit.mockResolvedValue(undefined); + mocks.selectQueue.length = 0; + mocks.selectQueue.push([target()], [target()]); mocks.rcon.disconnectUser.mockRejectedValueOnce( new Error("RCON unavailable"), ); @@ -1396,6 +1686,8 @@ describe("People production workflow adapter", () => { vi.clearAllMocks(); mocks.resolveServerContext.mockResolvedValue(context()); mocks.audit.mockRejectedValueOnce(new Error("audit unavailable")); + mocks.selectQueue.length = 0; + mocks.selectQueue.push([target()]); await expect( peopleMutationService.execute( { ...invocation, correlationId: "intent-failure" }, @@ -1410,7 +1702,10 @@ describe("People production workflow adapter", () => { }); it("rolls back mixed workflow intent failure before external delivery", async () => { - mocks.selectQueue.push([{ id: 9, state: 1, modId: 8 }]); + mocks.selectQueue.push( + [{ id: 9, state: 1, modId: 8, reportedId: 7 }], + [target()], + ); mocks.audit.mockRejectedValueOnce(new Error("intent audit unavailable")); const result = await peopleMutationService.execute( { ...invocation, legacy: false, correlationId: "cfh-intent-failure" }, @@ -1462,7 +1757,11 @@ describe("People production workflow adapter", () => { }); it("reports committed CFH database work plus failed external sync as typed partial", async () => { - mocks.selectQueue.push([{ id: 9, state: 1, modId: 8 }]); + mocks.selectQueue.push( + [{ id: 9, state: 1, modId: 8, reportedId: 7 }], + [target()], + [target()], + ); mocks.rcon.alertUser.mockResolvedValueOnce(false); const result = await peopleMutationService.execute( { ...invocation, legacy: false, correlationId: "cfh-partial" }, @@ -1478,7 +1777,7 @@ describe("People production workflow adapter", () => { }, correlationId: "cfh-partial", }); - expect(mocks.transaction).toHaveBeenCalledTimes(1); + expect(mocks.transaction).toHaveBeenCalledTimes(2); expect( mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome), ).toEqual(["intent", "partial"]); diff --git a/src/features/housekeeping/domains/people/services/mutations-production.test.ts b/src/features/housekeeping/domains/people/services/mutations-production.test.ts index a113203f..b9058fa8 100644 --- a/src/features/housekeeping/domains/people/services/mutations-production.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-production.test.ts @@ -2,14 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; -const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted( - () => ({ +const { audit, alertUser, resolveServerContext, selectFor, selectLimit } = + vi.hoisted(() => ({ audit: vi.fn(), alertUser: vi.fn(), resolveServerContext: vi.fn(), + selectFor: vi.fn(), selectLimit: vi.fn(), - }), -); + })); vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ getHousekeepingCapabilityContext: resolveServerContext, @@ -27,6 +27,15 @@ vi.mock("@/lib/db", async (importOriginal) => { where: vi.fn(() => ({ limit: selectLimit })), })), })), + transaction: vi.fn(async (callback) => + callback({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ for: selectFor })), + })), + })), + }), + ), }, }; }); @@ -70,6 +79,7 @@ beforeEach(() => { vi.clearAllMocks(); resolveServerContext.mockResolvedValue(capabilityContext([])); selectLimit.mockResolvedValue([target]); + selectFor.mockResolvedValue([target]); alertUser.mockResolvedValue(true); audit.mockResolvedValue(undefined); }); diff --git a/src/features/housekeeping/domains/people/services/mutations.ts b/src/features/housekeeping/domains/people/services/mutations.ts index 6cf459ad..6fa0870e 100644 --- a/src/features/housekeeping/domains/people/services/mutations.ts +++ b/src/features/housekeeping/domains/people/services/mutations.ts @@ -8,6 +8,7 @@ import { invalidateLoginCache } from "@/lib/auth"; import { hashPassword } from "@/lib/auth/password"; import { Ban, + type Db, db, GuildForumViews, Guilds, @@ -150,6 +151,13 @@ class PeopleMutationFailure extends Error { class ConfirmedExternalNoopFailure extends PeopleMutationFailure {} +class CompletedExternalEffectFailure extends Error { + constructor(readonly cause: unknown) { + super("External effect completed before the authority transaction failed"); + this.name = "CompletedExternalEffectFailure"; + } +} + function operationCapability(operation: PeopleMutationOperation) { if ( operation === "user.ban" || @@ -374,6 +382,8 @@ type TargetUser = { id: number; username: string; rank: number; + ipCurrent: string; + machineId: string; motto: string; credits: number; pixels: number; @@ -384,20 +394,24 @@ async function loadTarget( userId: number, context: PeopleMutationContext, guardHierarchy: boolean, + source: Pick = db, + lock = false, ): Promise { - const [target] = await db + const query = source .select({ id: User.id, username: User.username, rank: User.rank, + ipCurrent: User.ipCurrent, + machineId: User.machineId, motto: User.motto, credits: User.credits, pixels: User.pixels, online: User.online, }) .from(User) - .where(eq(User.id, userId)) - .limit(1); + .where(eq(User.id, userId)); + const [target] = lock ? await query.for("update") : await query.limit(1); if (!target) { throw new PeopleMutationFailure( "NOT_FOUND", @@ -417,6 +431,24 @@ async function loadTarget( return target; } +async function runWithLockedTargetAuthority( + userId: number, + context: PeopleMutationContext, + execute: () => Promise, +): Promise { + let externalCompleted = false; + try { + await db.transaction(async (tx) => { + await loadTarget(userId, context, true, tx, true); + await execute(); + externalCompleted = true; + }); + } catch (error) { + if (externalCompleted) throw new CompletedExternalEffectFailure(error); + throw error; + } +} + async function assertBulkTargetHierarchy( userIds: readonly number[], context: PeopleMutationContext, @@ -489,6 +521,28 @@ async function finalizeExternalWithAudit( try { await execute(); } catch (error) { + if (error instanceof CompletedExternalEffectFailure) { + let audit: HousekeepingPartialCompletion["audit"] = "persisted"; + try { + await logAudit( + canonicalAuditEntry( + context, + operation, + target, + targetId, + snapshot, + "partial", + ), + ); + } catch { + audit = "unavailable"; + } + return withPartialCompletion(snapshot, { + status: "partial", + external: "completed", + audit, + }); + } const failureSnapshot = mutationCommitted ? snapshot : error instanceof ConfirmedExternalNoopFailure @@ -607,20 +661,41 @@ async function executeUserMutation( before: null, after: { userId, alertDelivered: true }, }; - return runExternalWithAudit( + await db.transaction(async (tx) => { + await loadTarget(userId, context, true, tx, true); + await logAudit( + canonicalAuditEntry( + context, + operation, + "User", + userId, + snapshot, + "intent", + ), + tx, + ); + }); + return finalizeExternalWithAudit( context, operation, "User", userId, snapshot, - async () => requireRcon(await rcon.alertUser(userId, message)), + () => + runWithLockedTargetAuthority(userId, context, async () => { + await requireRcon(await rcon.alertUser(userId, message)); + }), + false, { before: null, after: { userId, alertDelivered: false } }, ); } - const guardHierarchy = operation !== "user.trade-lock"; - const target = await loadTarget(userId, context, guardHierarchy); - const before = targetSnapshot(target); + let target!: TargetUser; + let before!: ReturnType; + if (operation !== "user.trade-lock") { + target = await loadTarget(userId, context, true); + before = targetSnapshot(target); + } if (operation === "user.update") { const fields = record(data.fields); @@ -1093,6 +1168,8 @@ async function executeUserMutation( const locked = untilUnix > 0; let snapshot!: PeopleMutationSnapshot; await db.transaction(async (tx) => { + target = await loadTarget(userId, context, true, tx, true); + before = targetSnapshot(target); const [existing] = await tx .select({ id: Sanctions.id, @@ -1169,29 +1246,30 @@ async function executeUserMutation( "User", userId, snapshot, - async () => { - await requireRcon(await rcon.setTradeLock(userId, locked)); - await requireRcon( - await rcon.alertUser( - userId, - locked - ? "Trading has been disabled by staff." - : "Trading has been re-enabled by staff.", - ), - ); - if (target.online === "1") { - await requireRcon(await rcon.disconnectUser(userId, target.username)); - } - await logStaffActivity({ - staffId: context.capability.actor.id, - action: locked ? "trade_lock" : "trade_unlock", - description: locked - ? `Trade-locked ${target.username} (#${userId}) until ${untilUnix}` - : `Cleared trade lock for ${target.username} (#${userId})`, - targetType: "user", - targetId: userId, - }); - }, + () => + runWithLockedTargetAuthority(userId, context, async () => { + await requireRcon(await rcon.setTradeLock(userId, locked)); + await requireRcon( + await rcon.alertUser( + userId, + locked + ? "Trading has been disabled by staff." + : "Trading has been re-enabled by staff.", + ), + ); + if (target.online === "1") { + await requireRcon(await rcon.disconnectUser(userId, target.username)); + } + await logStaffActivity({ + staffId: context.capability.actor.id, + action: locked ? "trade_lock" : "trade_unlock", + description: locked + ? `Trade-locked ${target.username} (#${userId}) until ${untilUnix}` + : `Cleared trade lock for ${target.username} (#${userId})`, + targetType: "user", + targetId: userId, + }); + }), ); } async function executeBulkMutation( @@ -1956,6 +2034,8 @@ const moderationMutationExecutor = createPeopleModerationMutationExecutor({ nonNegativeInteger, normalizedText, requireRcon, + loadTarget, + runWithLockedTargetAuthority, transport: rcon, executeModerationAction, logStaffActivity, diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index c86e0381..5c5dcf87 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -140,9 +140,8 @@ describe("staff smoke contract", () => { const src = readFileSync("src/actions/bulk-users.ts", "utf8"); expect(src).toContain("bulkAdjustCurrency"); expect(src).toContain("setTradeLock"); - expect(src).toContain("tradeLockedUntil"); - expect(src).toContain("UsersSettings"); - expect(src).toContain("rcon.setTradeLock"); + expect(src).toContain("peopleMutationService.execute"); + expect(src).toContain('"user.trade-lock"'); }); it("deletes photos via Drizzle with local file purge helper", () => {