fix(housekeeping): release authority lock before activity
This commit is contained in:
1 parent
8a894617e7
commit
54f0345aac
3 files changed
+166
-41
No files matched your search
@@ -80,6 +80,7 @@ export interface PeopleModerationMutationDependencies {
|
||||
userId: number,
|
||||
context: PeopleModerationMutationContext,
|
||||
execute: () => Promise<void>,
|
||||
afterRelease?: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
readonly transport: ModerationTransport;
|
||||
readonly executeModerationAction: (
|
||||
@@ -406,22 +407,27 @@ export function createPeopleModerationMutationExecutor(
|
||||
banId,
|
||||
snapshot,
|
||||
() =>
|
||||
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);
|
||||
}),
|
||||
runWithLockedTargetAuthority(
|
||||
userId,
|
||||
context,
|
||||
async () => {
|
||||
let delivered = true;
|
||||
if (username !== null) {
|
||||
delivered = await transport.disconnectUser(userId, username);
|
||||
}
|
||||
await requireRcon(delivered);
|
||||
},
|
||||
() =>
|
||||
logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "user_ban",
|
||||
description: `Banned user #${userId} (${type}, ${
|
||||
hours > 0 ? `${hours}h` : "permanent"
|
||||
}): ${reason}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+104
@@ -319,6 +319,110 @@ describe("People production workflow adapter", () => {
|
||||
).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 () => {
|
||||
for (const [ticket, input, code] of [
|
||||
[
|
||||
|
||||
@@ -435,8 +435,10 @@ async function runWithLockedTargetAuthority(
|
||||
userId: number,
|
||||
context: PeopleMutationContext,
|
||||
execute: () => Promise<void>,
|
||||
afterRelease?: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
let externalCompleted = false;
|
||||
let completedFailure: CompletedExternalEffectFailure | undefined;
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await loadTarget(userId, context, true, tx, true);
|
||||
@@ -444,9 +446,15 @@ async function runWithLockedTargetAuthority(
|
||||
externalCompleted = true;
|
||||
});
|
||||
} catch (error) {
|
||||
if (externalCompleted) throw new CompletedExternalEffectFailure(error);
|
||||
throw error;
|
||||
if (!externalCompleted) throw error;
|
||||
completedFailure = new CompletedExternalEffectFailure(error);
|
||||
}
|
||||
try {
|
||||
await afterRelease?.();
|
||||
} catch (error) {
|
||||
completedFailure ??= new CompletedExternalEffectFailure(error);
|
||||
}
|
||||
if (completedFailure) throw completedFailure;
|
||||
}
|
||||
|
||||
async function assertBulkTargetHierarchy(
|
||||
@@ -1247,29 +1255,36 @@ async function executeUserMutation(
|
||||
userId,
|
||||
snapshot,
|
||||
() =>
|
||||
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,
|
||||
});
|
||||
}),
|
||||
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),
|
||||
);
|
||||
}
|
||||
},
|
||||
() =>
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user