fix(housekeeping): release authority lock before activity

This commit is contained in:
Simo committed 2026-09-05 11:25:29 +02:00
1 parent 8a894617e7
commit 54f0345aac
3 files changed
+135 -10

No files matched your search

@@ -80,6 +80,7 @@ export interface PeopleModerationMutationDependencies {
userId: number, userId: number,
context: PeopleModerationMutationContext, context: PeopleModerationMutationContext,
execute: () => Promise<void>, execute: () => Promise<void>,
afterRelease?: () => Promise<void>,
) => Promise<void>; ) => Promise<void>;
readonly transport: ModerationTransport; readonly transport: ModerationTransport;
readonly executeModerationAction: ( readonly executeModerationAction: (
@@ -406,12 +407,18 @@ export function createPeopleModerationMutationExecutor(
banId, banId,
snapshot, snapshot,
() => () =>
runWithLockedTargetAuthority(userId, context, async () => { runWithLockedTargetAuthority(
userId,
context,
async () => {
let delivered = true; let delivered = true;
if (username !== null) { if (username !== null) {
delivered = await transport.disconnectUser(userId, username); delivered = await transport.disconnectUser(userId, username);
} }
await logStaffActivity({ await requireRcon(delivered);
},
() =>
logStaffActivity({
staffId: context.capability.actor.id, staffId: context.capability.actor.id,
action: "user_ban", action: "user_ban",
description: `Banned user #${userId} (${type}, ${ description: `Banned user #${userId} (${type}, ${
@@ -419,9 +426,8 @@ export function createPeopleModerationMutationExecutor(
}): ${reason}`, }): ${reason}`,
targetType: "user", targetType: "user",
targetId: userId, targetId: userId,
});
await requireRcon(delivered);
}), }),
),
); );
} }
@@ -319,6 +319,110 @@ describe("People production workflow adapter", () => {
).toEqual(["intent", "partial"]); ).toEqual(["intent", "partial"]);
}); });
it.each([
[
"user.trade-lock",
{ userId: 7, untilUnix: 200 },
[[target()], [], [], [target()]],
"setTradeLock",
],
[
"ban.create",
{ userId: 7, hours: 24, type: "account", reason: "Abuse" },
[[target()], [target()]],
"disconnectUser",
],
] as const)(
"releases the user lock before legacy activity for %s",
async (operation, input, selectedRows, transportMethod) => {
let transactionDepth = 0;
let dispatchDepth = -1;
let activityDepth = -1;
mocks.selectQueue.push(...selectedRows.map((rows) => [...rows]));
const transaction = async (
callback: (tx: ReturnType<typeof databaseFacade>) => Promise<unknown>,
) => {
transactionDepth += 1;
try {
return await callback(databaseFacade());
} finally {
transactionDepth -= 1;
}
};
mocks.transaction
.mockImplementationOnce(transaction)
.mockImplementationOnce(transaction);
mocks.rcon[transportMethod].mockImplementationOnce(async () => {
dispatchDepth = transactionDepth;
return true;
});
mocks.logStaffActivity.mockImplementationOnce(async () => {
activityDepth = transactionDepth;
});
const result = await peopleMutationService.execute(
{ ...invocation, legacy: false },
operation,
input,
);
expect(result).toMatchObject({ ok: true });
expect(dispatchDepth).toBe(1);
expect(activityDepth).toBe(0);
expect(mocks.logStaffActivity).toHaveBeenCalledTimes(1);
},
);
it("preserves known completion when released activity also fails", async () => {
let transactionDepth = 0;
let dispatchCompleted = false;
let activityDepth = -1;
mocks.selectQueue.push([target()], [], [], [target()]);
mocks.transaction
.mockImplementationOnce(async (callback) => callback(databaseFacade()))
.mockImplementationOnce(async (callback) => {
transactionDepth += 1;
try {
const result = await callback(databaseFacade());
if (dispatchCompleted) {
throw new Error("read-only authority commit failed");
}
return result;
} finally {
transactionDepth -= 1;
}
});
mocks.rcon.setTradeLock.mockImplementationOnce(async () => {
dispatchCompleted = true;
return true;
});
mocks.logStaffActivity.mockImplementationOnce(async () => {
activityDepth = transactionDepth;
throw new Error("activity transport failed");
});
const result = await peopleMutationService.execute(
{
...invocation,
legacy: false,
correlationId: "released-activity-failed",
},
"user.trade-lock",
{ userId: 7, untilUnix: 200 },
);
expect(activityDepth).toBe(0);
expect(result).toMatchObject({
ok: true,
completion: {
status: "partial",
external: "completed",
audit: "persisted",
},
});
expect(mocks.rcon.setTradeLock).toHaveBeenCalledTimes(1);
});
it("rejects forged, closed, and non-user CFH sanctions before mutation", async () => { it("rejects forged, closed, and non-user CFH sanctions before mutation", async () => {
for (const [ticket, input, code] of [ for (const [ticket, input, code] of [
[ [
@@ -435,8 +435,10 @@ async function runWithLockedTargetAuthority(
userId: number, userId: number,
context: PeopleMutationContext, context: PeopleMutationContext,
execute: () => Promise<void>, execute: () => Promise<void>,
afterRelease?: () => Promise<void>,
): Promise<void> { ): Promise<void> {
let externalCompleted = false; let externalCompleted = false;
let completedFailure: CompletedExternalEffectFailure | undefined;
try { try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await loadTarget(userId, context, true, tx, true); await loadTarget(userId, context, true, tx, true);
@@ -444,9 +446,15 @@ async function runWithLockedTargetAuthority(
externalCompleted = true; externalCompleted = true;
}); });
} catch (error) { } catch (error) {
if (externalCompleted) throw new CompletedExternalEffectFailure(error); if (!externalCompleted) throw error;
throw error; completedFailure = new CompletedExternalEffectFailure(error);
} }
try {
await afterRelease?.();
} catch (error) {
completedFailure ??= new CompletedExternalEffectFailure(error);
}
if (completedFailure) throw completedFailure;
} }
async function assertBulkTargetHierarchy( async function assertBulkTargetHierarchy(
@@ -1247,7 +1255,10 @@ async function executeUserMutation(
userId, userId,
snapshot, snapshot,
() => () =>
runWithLockedTargetAuthority(userId, context, async () => { runWithLockedTargetAuthority(
userId,
context,
async () => {
await requireRcon(await rcon.setTradeLock(userId, locked)); await requireRcon(await rcon.setTradeLock(userId, locked));
await requireRcon( await requireRcon(
await rcon.alertUser( await rcon.alertUser(
@@ -1258,9 +1269,13 @@ async function executeUserMutation(
), ),
); );
if (target.online === "1") { if (target.online === "1") {
await requireRcon(await rcon.disconnectUser(userId, target.username)); await requireRcon(
await rcon.disconnectUser(userId, target.username),
);
} }
await logStaffActivity({ },
() =>
logStaffActivity({
staffId: context.capability.actor.id, staffId: context.capability.actor.id,
action: locked ? "trade_lock" : "trade_unlock", action: locked ? "trade_lock" : "trade_unlock",
description: locked description: locked
@@ -1268,8 +1283,8 @@ async function executeUserMutation(
: `Cleared trade lock for ${target.username} (#${userId})`, : `Cleared trade lock for ${target.username} (#${userId})`,
targetType: "user", targetType: "user",
targetId: userId, targetId: userId,
});
}), }),
),
); );
} }
async function executeBulkMutation( async function executeBulkMutation(