fix(housekeeping): coordinate all rank mutation paths
CI / check (pull_request) Successful in 1m44s
CI / deploy (pull_request) Skipped
CI / e2e (pull_request) Skipped

This commit is contained in:
Simo committed 2026-09-05 09:48:30 +02:00
1 parent 3d53321575
commit 866f38818b
14 files changed
+1070 -317

No files matched your search

+24 -39
View File
@@ -1,9 +1,10 @@
"use server";
import { eq, sql } from "drizzle-orm";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -198,44 +199,28 @@ const setRankSchema = z.object({
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const [target] = await db
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, ctx.data.userId))
.limit(1);
if (!target) throw new ActionError("User not found");
let rankExists: { id: number }[] = [];
try {
const [rows] = await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
);
rankExists = rows as unknown as { id: number }[];
} catch {
rankExists = [];
}
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) {
if (target.rank >= staffRank) {
throw new ActionError(
"Cannot change rank of a user at or above your rank",
);
}
if (ctx.data.rank >= staffRank) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await db
.update(User)
.set({ rank: ctx.data.rank })
.where(eq(User.id, ctx.data.userId));
const result = await systemMutationService.execute(
{
capability: createHousekeepingCapabilityContext(
{
id: Number(ctx.session.user.id),
username: ctx.session.user.username,
rank: Number(ctx.session.user.rank),
},
ctx.permissions,
),
correlationId: crypto.randomUUID(),
},
"rcon.set-rank",
ctx.data,
);
if (!result.ok) throw new ActionError(result.error.messageKey);
revalidatePath(PATH);
return actionOk();
if (result.completion)
throw new ActionError(
`Rank saved; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`,
);
return actionOk(result.data as Record<string, unknown>);
},
);
+25 -45
View File
@@ -1,23 +1,18 @@
"use server";
import { and, count, eq, inArray, sql } from "drizzle-orm";
import crypto from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache";
import { z } from "zod";
import {
AclModelPermission,
AclModelRole,
AclPermission,
AclRole,
db,
User,
} from "@/lib/db";
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { AclModelPermission, AclPermission, AclRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import {
createEmulatorRank,
deleteEmulatorRank,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon";
@@ -58,42 +53,27 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const [userCount] = await db
.select({ total: count() })
.from(User)
.where(eq(User.rank, ctx.data.id));
const users = userCount?.total ?? 0;
if (users > 0)
throw new ActionError(`Cannot delete: ${users} users have this rank`);
const [role] = await db
.select({ id: AclRole.id })
.from(AclRole)
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
.limit(1);
await deleteEmulatorRank(db, ctx.data.id);
if (role) {
await db.transaction(async (tx) => {
await tx
.delete(AclModelPermission)
.where(
and(
eq(AclModelPermission.modelId, role.id),
eq(AclModelPermission.modelType, "Role"),
),
);
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
});
}
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_delete",
description: `Deleted rank #${ctx.data.id}`,
targetType: "rank",
targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
const result = await systemMutationService.execute(
{
capability: createHousekeepingCapabilityContext(
{
id: Number(ctx.session.user.id),
username: ctx.session.user.username,
rank: Number(ctx.session.user.rank),
},
ctx.permissions,
),
correlationId: crypto.randomUUID(),
},
"access.rank.delete",
ctx.data,
);
if (!result.ok) throw new ActionError(result.error.messageKey);
revalidateTag("permissions", { expire: 0 });
if (result.completion)
throw new ActionError(
`Rank deleted; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`,
);
return actionOk();
},
);
+148
View File
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const d = vi.hoisted(() => ({
rows: [] as unknown[][],
events: [] as string[],
set: vi.fn(),
execute: vi.fn(),
audit: vi.fn(),
synchronize: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: 8 } }) }));
vi.mock("@/lib/rate-limit", () => ({
clientIp: async () => "local",
rateLimit: async () => ({ ok: true }),
}));
vi.mock("@/lib/services/paypal", () => ({ creditsPerUnit: () => 10 }));
vi.mock("@/lib/services/send-currency", () => ({
currencyDb: {},
sendCurrency: vi.fn(),
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: vi.fn() } }));
vi.mock("@/lib/services/audit", () => ({ logAudit: d.audit }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({
redirect: (url: string) => {
throw Object.assign(new Error(url), { digest: "NEXT_REDIRECT" });
},
}));
vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
const facade = {
execute: d.execute,
select: () => ({
from: () => ({
where: () => ({
limit: async () => d.rows.shift() ?? [],
for: async () => {
d.events.push("user-lock");
return d.rows.shift() ?? [];
},
}),
}),
}),
update: () => ({ set: d.set }),
};
return {
...actual,
db: {
...facade,
transaction: async (run: (tx: typeof facade) => unknown) => {
const result = await run(facade);
d.events.push("commit");
return result;
},
},
};
});
vi.mock("@/lib/services/rank-assignment", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/rank-assignment")>()),
rankAssignmentCoordinator: { synchronize: d.synchronize },
}));
import { buyShopArticle } from "./shop";
function input() {
const data = new FormData();
data.set("articleId", "1");
data.set("categoryId", "1");
return data;
}
beforeEach(() => {
vi.clearAllMocks();
d.rows.length = 0;
d.events.length = 0;
d.rows.push(
[
{
id: 1,
name: "Member",
costs: 100,
giveRank: 4,
badges: "",
credits: 0,
duckets: 0,
diamonds: 0,
},
],
[{ credits: 100, rank: 2 }],
);
d.execute.mockImplementation(async () => {
d.events.push("rank-lock");
return [[{ id: 4 }]];
});
d.set.mockImplementation(() => ({
where: async () => {
d.events.push("update");
},
}));
d.audit.mockResolvedValue(undefined);
d.synchronize.mockImplementation(async () => {
d.events.push("delivery");
return { status: "delivered", rank: 4 };
});
});
describe("shop rank coordination", () => {
it("locks rank before user, commits the purchase, then synchronizes", async () => {
d.rows.push([{ credits: 100, rank: 2 }]);
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
expect(d.events).toEqual([
"rank-lock",
"user-lock",
"update",
"update",
"commit",
"delivery",
]);
expect(d.set).toHaveBeenCalledWith({ rank: 4 });
expect(d.audit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
"intent",
"success",
]);
});
it("does not overwrite a newer staff promotion with the previously observed buyer rank", async () => {
d.rows.push([{ credits: 100, rank: 6 }]);
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
expect(d.set).not.toHaveBeenCalledWith({ rank: 4 });
expect(d.synchronize).not.toHaveBeenCalled();
});
it("does not charge when the configured package rank was deleted", async () => {
d.execute.mockResolvedValueOnce([[]]);
await expect(buyShopArticle(input())).rejects.toThrow("error=error");
expect(d.set).not.toHaveBeenCalled();
expect(d.synchronize).not.toHaveBeenCalled();
});
it("keeps a durable partial audit without turning a committed purchase into a retry", async () => {
d.rows.push([{ credits: 100, rank: 2 }]);
d.synchronize.mockResolvedValueOnce({ status: "pending", rank: 4 });
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
expect(d.audit).toHaveBeenLastCalledWith(
expect.objectContaining({
outcome: "partial",
correlationId: expect.any(String),
}),
);
});
});
+61 -1
View File
@@ -1,5 +1,6 @@
"use server";
import crypto from "node:crypto";
import { and, eq, max, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
@@ -7,7 +8,12 @@ import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { logAudit } from "@/lib/services/audit";
import { creditsPerUnit } from "@/lib/services/paypal";
import {
lockConfiguredRank,
rankAssignmentCoordinator,
} from "@/lib/services/rank-assignment";
import { rcon } from "@/lib/services/rcon";
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
@@ -118,8 +124,38 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
outcome = "credits";
} else {
const badgeCodes = parseBadgeCodes(article.badges);
let rankChanged = false;
const rankRecoveryId = crypto.randomUUID();
const rankAudit = {
userId,
action: "system.external-sync",
target: "rcon.set-rank",
targetId: userId,
correlationId: rankRecoveryId,
domain: "system" as const,
after: {
kind: "set-rank",
operation: "rcon.set-rank",
userId,
rank: article.giveRank,
},
};
await db.transaction(async (tx) => {
if (
article.giveRank != null &&
article.giveRank > 0 &&
!(await lockConfiguredRank(tx, article.giveRank))
) {
throw new Error("Package rank no longer exists");
}
const [lockedBuyer] = await tx
.select({ credits: User.credits, rank: User.rank })
.from(User)
.where(eq(User.id, userId))
.for("update");
if (!lockedBuyer || lockedBuyer.credits < price)
throw new Error("Insufficient credits");
if (price > 0) {
await tx
.update(User)
@@ -130,12 +166,14 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
if (
article.giveRank != null &&
article.giveRank > 0 &&
article.giveRank > buyer.rank
article.giveRank > lockedBuyer.rank
) {
await tx
.update(User)
.set({ rank: article.giveRank })
.where(eq(User.id, userId));
rankChanged = true;
await logAudit({ ...rankAudit, outcome: "intent" }, tx);
}
for (const code of badgeCodes) {
@@ -164,6 +202,28 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
}
});
if (rankChanged) {
try {
const delivery =
await rankAssignmentCoordinator.synchronize(userId);
await logAudit({
...rankAudit,
after: {
...rankAudit.after,
...(delivery.status === "superseded"
? { superseded: true }
: { rank: delivery.rank }),
},
outcome:
delivery.status === "pending" ? "partial" : "success",
});
} catch (error) {
logServerError("shop.rank_sync_pending", error, {
userId,
correlationId: rankRecoveryId,
});
}
}
await sendCurrency(
{ rcon, db: currencyDb },
userId,
+19 -60
View File
@@ -3,6 +3,7 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations";
import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password";
import {
@@ -17,6 +18,7 @@ import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { lockConfiguredRank } from "@/lib/services/rank-assignment";
import { rcon } from "@/lib/services/rcon";
import { notify } from "@/lib/services/webhook";
import {
@@ -55,7 +57,7 @@ export const createUser = adminAction(
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data;
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
if (rank >= ctx.session.user.rank && !ctx.permissions.isSuperAdmin) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
@@ -64,6 +66,9 @@ export const createUser = adminAction(
try {
const user = await db.transaction(async (tx) => {
if (!(await lockConfiguredRank(tx, rank))) {
throw new ActionError("Rank does not exist");
}
const [result] = await tx.insert(User).values({
username,
mail,
@@ -123,66 +128,20 @@ const updateUserInput = updateUserSchema.extend({
export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id, diamonds, duckets, ...userData } = ctx.data;
const targetUser = await guardRank(id, ctx.session.user.rank);
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const patch = Object.fromEntries(
Object.entries(userData).filter(([, v]) => v !== undefined),
) as Partial<{
username: string;
mail: string;
rank: number;
motto: string;
credits: number;
pixels: number;
}>;
if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id));
}
invalidateLoginCache(targetUser.username);
if (diamonds !== undefined) {
await db
.insert(UsersCurrency)
.values({ userId: id, type: 5, amount: diamonds })
.onDuplicateKeyUpdate({ set: { amount: diamonds } });
}
if (duckets !== undefined) {
await db
.insert(UsersCurrency)
.values({ userId: id, type: 0, amount: duckets })
.onDuplicateKeyUpdate({ set: { amount: duckets } });
}
logAudit({
userId: ctx.session.user.id,
action: "user_edit",
target: "User",
targetId: id,
before: {
username: targetUser.username,
mail: targetUser.mail,
rank: targetUser.rank,
const { id, ...fields } = ctx.data;
const result = await peopleMutationService.execute(
{
correlationId: crypto.randomUUID(),
expectedActorId: Number(ctx.session.user.id),
},
after: userData,
});
notify({
action: "user_edit",
actor: ctx.session.user.username,
target: targetUser.username,
targetId: id,
});
"user.update",
{ userId: id, fields },
);
if (!result.ok) throw new ActionError(result.error.messageKey);
if (result.completion)
throw new ActionError(
`User saved; synchronization or completion audit is pending. Reference: ${result.correlationId}`,
);
return actionOk();
},
);
+30 -61
View File
@@ -1,7 +1,7 @@
import { eq, sql } from "drizzle-orm";
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations";
import { withAdmin } from "@/lib/api-handler";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -10,7 +10,6 @@ export const POST = withAdmin(
{ permission: PERMS.USERS_EDIT },
async (request, context) => {
const staffId = context.session.user.id;
const staffRank = context.session.user.rank;
const formData = await request.formData();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") || "");
@@ -32,68 +31,38 @@ export const POST = withAdmin(
);
}
const [rankExists] = (await db
.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
)
.catch(() => [[]] as unknown as [unknown[], unknown])) as unknown as [
{ id: number }[],
unknown,
];
if (rankExists.length === 0) {
const result = await peopleMutationService.execute(
{
correlationId: crypto.randomUUID(),
expectedActorId: Number(staffId),
},
"user.update",
{ userId, fields: { rank } },
);
if (!result.ok) {
const status =
result.error.code === "FORBIDDEN"
? 403
: result.error.code === "NOT_FOUND"
? 404
: result.error.code === "VALIDATION"
? 400
: 503;
return NextResponse.json(
{ success: false, message: "Rank does not exist" },
{ status: 400 },
{ success: false, message: result.error.messageKey },
{ status },
);
}
const [target] = await db
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!target) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 },
);
}
const isSuper = context.permissions.isSuperAdmin;
if (!isSuper) {
if (target.rank >= staffRank) {
return NextResponse.json(
{
success: false,
message: "Cannot change rank of a user at or above your rank",
},
{ status: 403 },
);
}
if (rank >= staffRank) {
return NextResponse.json(
{
success: false,
message: "Cannot set a rank equal to or above your own",
},
{ status: 403 },
);
}
}
await db.update(User).set({ rank }).where(eq(User.id, userId));
await rcon.setRank(userId, rank);
await logStaffActivity({
staffId,
action: "rank_change",
description: `Set rank of user #${userId} to ${rank}`,
targetType: "user",
targetId: userId,
});
return NextResponse.json(
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
{ status: 200 },
{
success: true,
message: result.completion
? "Rank saved; emulator synchronization or completion audit is pending."
: "Rank updated",
completion: result.completion,
correlationId: result.correlationId,
},
{ status: result.completion ? 202 : 200 },
);
}
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
kickAll: vi.fn(),
muteUser: vi.fn(),
setTradeLock: vi.fn(),
setRank: vi.fn(),
staffAlert: vi.fn(),
unmuteUser: vi.fn(),
updateWordFilter: vi.fn(),
@@ -46,6 +47,7 @@ function mutationPromise(
function selectResult() {
const value = mocks.selectQueue.shift() ?? [];
return Object.assign(Promise.resolve(value), {
for: vi.fn(async () => value),
limit: vi.fn(async () => value),
orderBy: vi.fn(() =>
Object.assign(Promise.resolve(value), {
@@ -173,6 +175,36 @@ beforeEach(() => {
});
describe("People production workflow adapter", () => {
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) => {
if (
entry.action === "system.external-sync" &&
entry.outcome === "success"
)
throw new Error("audit unavailable");
});
const result = await peopleMutationService.execute(
{ ...invocation, legacy: false },
"user.update",
{ userId: 7, fields: { rank: 4 } },
);
expect(result).toMatchObject({
ok: true,
completion: {
status: "partial",
external: "completed",
audit: "unavailable",
},
data: {
after: {
rankRecoveryId: "production-workflow",
rankCompletionAudit: "pending",
},
},
});
expect(mocks.rcon.setRank).toHaveBeenCalledWith(7, 4);
});
it("does not treat a high numeric rank as a super-admin hierarchy bypass", async () => {
mocks.resolveServerContext.mockResolvedValue({
...context(),
@@ -231,7 +263,8 @@ describe("People production workflow adapter", () => {
fieldErrors: { rank: ["errors.validation.invalid"] },
},
});
expect(mocks.transaction).not.toHaveBeenCalled();
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.updateSet).not.toHaveBeenCalled();
});
it("assigns an existing configured rank above 7 for a super-admin", async () => {
@@ -242,6 +275,7 @@ describe("People production workflow adapter", () => {
});
mocks.selectQueue.push([target({ rank: 2 })]);
mocks.execute.mockResolvedValueOnce([[{ id: 9 }], []]);
mocks.selectQueue.push([target({ rank: 2 })], [target({ rank: 9 })]);
const result = await peopleMutationService.execute(
invocation,
@@ -253,7 +287,8 @@ describe("People production workflow adapter", () => {
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ rank: 9 }),
);
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.transaction).toHaveBeenCalledTimes(2);
expect(mocks.rcon.setRank).toHaveBeenCalledWith(7, 9);
});
it.each([
@@ -34,6 +34,11 @@ import {
executeModerationAction,
reloadWordFilter,
} from "@/lib/services/moderation";
import {
RankAssignmentFailure,
type RankAssignmentTransaction,
rankAssignmentCoordinator,
} from "@/lib/services/rank-assignment";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -438,17 +443,6 @@ async function assertBulkTargetHierarchy(
}
}
async function assertConfiguredRankExists(rank: number): Promise<void> {
const [rows] = (await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
)) as unknown as [unknown[], unknown];
if (!Array.isArray(rows) || rows.length === 0) {
throw new PeopleMutationFailure("VALIDATION", "errors.validation.invalid", {
rank: ["errors.validation.invalid"],
});
}
}
function targetSnapshot(target: TargetUser) {
return {
id: target.id,
@@ -642,22 +636,39 @@ async function executeUserMutation(
"errors.housekeeping.forbidden",
);
}
await assertConfiguredRankExists(rank);
}
const userPatch = Object.fromEntries(
["username", "mail", "rank", "motto", "credits", "pixels"].flatMap(
(key) => (fields[key] === undefined ? [] : [[key, fields[key]]]),
["username", "mail", "motto", "credits", "pixels"].flatMap((key) =>
fields[key] === undefined ? [] : [[key, fields[key]]],
),
);
const safeFields = Object.fromEntries(
Object.entries(fields).filter(([key]) => key !== "mail"),
);
if (fields.mail !== undefined) safeFields.mailChanged = true;
const after: Record<string, unknown> = { ...before, ...safeFields };
const rankSyncAudit = {
userId: context.capability.actor.id,
action: "system.external-sync",
target: "rcon.set-rank",
targetId: userId,
correlationId: context.correlationId,
domain: "system" as const,
after: {
kind: "set-rank",
operation: "rcon.set-rank",
userId,
rank: nextRank,
},
};
if (nextRank !== undefined) after.rankRecoveryId = context.correlationId;
const snapshot: PeopleMutationSnapshot = {
before,
after: { ...before, ...safeFields },
after,
};
await db.transaction(async (tx) => {
const persist = async (tx: RankAssignmentTransaction) => {
if (nextRank !== undefined)
await logAudit({ ...rankSyncAudit, outcome: "intent" }, tx);
if (Object.keys(userPatch).length > 0) {
await tx.update(User).set(userPatch).where(eq(User.id, userId));
}
@@ -683,8 +694,49 @@ async function executeUserMutation(
),
tx,
);
});
return finalizeExternalWithAudit(
};
if (nextRank !== undefined) {
try {
await rankAssignmentCoordinator.commit({
userId,
rank: positiveInteger(nextRank),
authorize(currentRank) {
before.rank = currentRank;
if (
!context.capability.isSuperAdmin &&
currentRank >= context.capability.actor.rank
) {
throw new PeopleMutationFailure(
"FORBIDDEN",
"errors.housekeeping.forbidden",
);
}
},
mutate: persist,
});
} catch (error) {
if (error instanceof RankAssignmentFailure) {
if (error.code === "RANK_NOT_FOUND") {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.validation.invalid",
{
rank: ["errors.validation.invalid"],
},
);
}
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
throw error;
}
} else {
await db.transaction(persist);
}
let rankAuditUnavailable = false;
const completed = await finalizeExternalWithAudit(
context,
operation,
"User",
@@ -692,6 +744,33 @@ async function executeUserMutation(
snapshot,
async () => {
invalidateLoginCache(target.username);
if (nextRank !== undefined) {
const delivery = await rankAssignmentCoordinator.synchronize(userId);
after.rankSynchronization = delivery.status;
if (delivery.status !== "superseded")
after.synchronizedRank = delivery.rank;
try {
await logAudit({
...rankSyncAudit,
after: {
...rankSyncAudit.after,
...(delivery.status === "superseded"
? { superseded: true }
: { rank: delivery.rank }),
},
outcome: delivery.status === "pending" ? "partial" : "success",
});
} catch {
rankAuditUnavailable = true;
after.rankCompletionAudit = "pending";
}
if (delivery.status === "pending") {
throw new PeopleMutationFailure(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
);
}
}
void notify({
action: "user_edit",
actor: context.capability.actor.username,
@@ -700,6 +779,14 @@ async function executeUserMutation(
});
},
);
if (rankAuditUnavailable) {
return withPartialCompletion(completed, {
status: "partial",
external: completed.completion?.external ?? "completed",
audit: "unavailable",
});
}
return completed;
}
if (operation === "user.ban") {
@@ -29,6 +29,11 @@ import {
prepareEmulatorRankCreation,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import {
lockConfiguredRank,
RankAssignmentFailure,
rankAssignmentCoordinator,
} from "@/lib/services/rank-assignment";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import {
@@ -164,6 +169,15 @@ export function createSystemMutationService(
context.correlationId,
);
} catch (error) {
if (error instanceof RankAssignmentFailure) {
return fail(
"NOT_FOUND",
error.code === "RANK_NOT_FOUND"
? "errors.housekeeping.system.rankNotFound"
: "errors.housekeeping.system.userNotFound",
context.correlationId,
);
}
if (error instanceof SystemCommittedExternalFailure) {
return ok(error.data, context.correlationId, {
status: "partial",
@@ -301,7 +315,11 @@ function synchronizationData(
synchronization,
completed:
synchronization === "completed"
? ["database", "audit", pendingExternal(intent)]
? [
"database",
"audit",
extra.superseded ? "target-deleted" : pendingExternal(intent),
]
: ["database", "audit"],
pending: synchronization === "completed" ? [] : [pendingExternal(intent)],
};
@@ -312,6 +330,7 @@ async function executeExternalSynchronization(
): Promise<{
readonly intent: SystemExternalSyncIntent;
readonly synchronized: boolean;
readonly superseded?: boolean;
}> {
if (intent.kind !== "set-rank") {
return {
@@ -320,34 +339,14 @@ async function executeExternalSynchronization(
};
}
return db.transaction(async (tx) => {
const [target] = await tx
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, intent.userId))
.for("update");
if (!target) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.userNotFound",
);
}
const currentIntent = {
...intent,
rank: positiveInteger(target.rank),
} as const satisfies SystemExternalSyncIntent;
let synchronized = false;
try {
synchronized = await rcon.setRank(
currentIntent.userId,
currentIntent.rank,
);
} catch {
// Preserve the resolved current intent for a later convergent retry.
}
return { intent: currentIntent, synchronized };
});
const delivery = await rankAssignmentCoordinator.synchronize(intent.userId);
if (delivery.status === "superseded") {
return { intent, synchronized: true, superseded: true };
}
return {
intent: { ...intent, rank: delivery.rank },
synchronized: delivery.status === "delivered",
};
}
async function completeExternalSynchronization(
@@ -358,10 +357,12 @@ async function completeExternalSynchronization(
): Promise<unknown> {
let currentIntent = intent;
let synchronized = false;
let superseded = false;
try {
const execution = await executeExternalSynchronization(intent);
currentIntent = execution.intent;
synchronized = execution.synchronized;
superseded = execution.superseded ?? false;
} catch (error) {
if (error instanceof SystemMutationFailure) throw error;
}
@@ -382,13 +383,18 @@ async function completeExternalSynchronization(
}
try {
await logAudit(
syncAuditEntry(currentIntent, context, recoveryId, "success"),
);
const entry = syncAuditEntry(currentIntent, context, recoveryId, "success");
await logAudit({
...entry,
after: { ...entry.after, ...(superseded ? { superseded: true } : {}) },
});
} catch {
throw new SystemCommittedExternalFailure(
{
...synchronizationData(currentIntent, recoveryId, "completed", extra),
...synchronizationData(currentIntent, recoveryId, "completed", {
...extra,
...(superseded ? { superseded: true } : {}),
}),
pending: ["completion-audit"],
},
"completed",
@@ -396,7 +402,10 @@ async function completeExternalSynchronization(
);
}
return synchronizationData(currentIntent, recoveryId, "completed", extra);
return synchronizationData(currentIntent, recoveryId, "completed", {
...extra,
...(superseded ? { superseded: true } : {}),
});
}
function parseExternalSyncIntent(
@@ -570,16 +579,7 @@ async function executeAccessMutation(
rankId: id,
} as const satisfies SystemExternalSyncIntent;
await db.transaction(async (tx) => {
let rankRows: { id: number }[] = [];
try {
const [rows] = await tx.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${id} LIMIT 1 FOR UPDATE`,
);
rankRows = rows as unknown as { id: number }[];
} catch {
rankRows = [];
}
if (rankRows.length === 0) {
if (!(await lockConfiguredRank(tx, id))) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.rankNotFound",
@@ -978,63 +978,40 @@ async function executeRconMutation(
userId,
rank,
} as const satisfies SystemExternalSyncIntent;
await db.transaction(async (tx) => {
let rankRows: { id: number }[] = [];
try {
const [rows] = await tx.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1 FOR UPDATE`,
);
rankRows = rows as unknown as { id: number }[];
} catch {
rankRows = [];
}
if (rankRows.length === 0) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.rankNotFound",
);
}
const [target] = await tx
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, userId))
.for("update");
if (!target) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.userNotFound",
);
}
if (!context.capability.isSuperAdmin) {
const actorRank = context.capability.actor.rank;
if (target.rank >= actorRank) {
await rankAssignmentCoordinator.commit({
userId,
rank,
authorize(currentRank) {
if (context.capability.isSuperAdmin) return;
if (currentRank >= context.capability.actor.rank) {
throw new SystemMutationFailure(
"FORBIDDEN",
"errors.housekeeping.system.cannotChangePeerRank",
);
}
if (rank >= actorRank) {
if (rank >= context.capability.actor.rank) {
throw new SystemMutationFailure(
"FORBIDDEN",
"errors.housekeeping.system.cannotAssignPeerRank",
);
}
}
await tx.update(User).set({ rank }).where(eq(User.id, userId));
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
action: "rank_assign",
description: `Assigned rank #${rank} to user #${userId}`,
targetType: "user",
targetId: userId,
},
tx,
);
await logAudit(
syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
);
},
async mutate(tx) {
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
action: "rank_assign",
description: `Assigned rank #${rank} to user #${userId}`,
targetType: "user",
targetId: userId,
},
tx,
);
await logAudit(
syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
);
},
});
return completeExternalSynchronization(intent, context, undefined, {
userId,
@@ -186,6 +186,47 @@ function expectPendingSynchronization(
}
describe("durable rank synchronization", () => {
it.each([
["access.rank.delete", PERMS.PERMISSIONS_MANAGE, { id: 7 }],
["rcon.set-rank", PERMS.RCON_EXECUTE, { userId: 8, rank: 4 }],
] as const)(
"reports SQL lock failure for %s as dependency unavailable",
async (operation, permission, input) => {
doubles.dbExecute.mockRejectedValueOnce(new Error("lock wait timeout"));
const result = await systemMutationService.execute(
serviceContext(permission),
operation,
input,
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(doubles.dbUpdate).not.toHaveBeenCalled();
expect(doubles.rconSetRank).not.toHaveBeenCalled();
expect(doubles.deleteEmulatorRankInTransaction).not.toHaveBeenCalled();
},
);
it("audits a committed assignment as superseded when the user was subsequently deleted", async () => {
doubles.dbExecute.mockResolvedValueOnce([[{ id: 4 }]]);
doubles.dbSelect
.mockImplementationOnce(() => limitedSelection([{ rank: 3 }]))
.mockImplementationOnce(() => limitedSelection([]));
const result = await systemMutationService.execute(
serviceContext(PERMS.RCON_EXECUTE),
"rcon.set-rank",
{ userId: 8, rank: 4 },
);
expect(result).toMatchObject({
ok: true,
data: { superseded: true, recoveryId: "rank-mutation-correlation" },
});
expect(doubles.rconSetRank).not.toHaveBeenCalled();
expect(doubles.logAudit).toHaveBeenLastCalledWith(
expect.objectContaining({ outcome: "success" }),
);
});
it("rolls back before RCON when the transaction-bound recovery intent cannot be audited", async () => {
doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable"));
doubles.rconSend.mockResolvedValue(true);
+185
View File
@@ -0,0 +1,185 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
import {
createRankAssignmentCoordinator,
lockConfiguredRank,
type RankAssignmentAdapter,
} from "./rank-assignment";
function adapterFixture(overrides: Partial<RankAssignmentAdapter> = {}) {
const events: string[] = [];
let transactionNumber = 0;
const adapter: RankAssignmentAdapter = {
transaction: async (run) => {
transactionNumber += 1;
const transaction = { transactionNumber };
events.push(`transaction:${transactionNumber}:start`);
const result = await run(transaction);
events.push(`transaction:${transactionNumber}:commit`);
return result;
},
lockRank: async (_transaction, rank) => {
events.push(`rank:${rank}:for-update`);
return true;
},
lockUser: vi
.fn()
.mockImplementationOnce(async (_transaction, userId) => {
events.push(`user:${userId}:for-update`);
return { rank: 3 };
})
.mockImplementationOnce(async (_transaction, userId) => {
events.push(`user:${userId}:for-update`);
return { rank: 5 };
}),
updateUserRank: async (_transaction, userId, rank) => {
events.push(`user:${userId}:update:${rank}`);
},
deliverRank: async (userId, rank) => {
events.push(`rcon:${userId}:${rank}`);
return true;
},
...overrides,
};
return { adapter, events };
}
describe("rank assignment coordinator", () => {
it("uses a parameterized FOR UPDATE query for the configured rank lock", async () => {
const execute = vi.fn().mockResolvedValue([[{ id: 4 }]]);
expect(await lockConfiguredRank({ execute } as never, 4)).toBe(true);
const query = new MySqlDialect().sqlToQuery(execute.mock.calls[0][0]);
expect(query.sql).toBe(
"SELECT id FROM permission_ranks WHERE id = ? LIMIT 1 FOR UPDATE",
);
expect(query.params).toEqual([4]);
});
it("does not release the delivery transaction before the transport settles", async () => {
let finishDelivery!: (value: boolean) => void;
let started!: () => void;
const deliveryStarted = new Promise<void>((resolve) => {
started = resolve;
});
const { adapter, events } = adapterFixture({
deliverRank: () => {
started();
return new Promise<boolean>((resolve) => {
finishDelivery = resolve;
});
},
});
const pending = createRankAssignmentCoordinator(adapter).assign({
userId: 8,
rank: 4,
});
await deliveryStarted;
expect(events).toContain("transaction:1:commit");
expect(events).not.toContain("transaction:2:commit");
finishDelivery(true);
await expect(pending).resolves.toEqual({ status: "delivered", rank: 5 });
expect(events.at(-1)).toBe("transaction:2:commit");
});
it("never sends RCON when the transactional audit fails", async () => {
const { adapter, events } = adapterFixture();
await expect(
createRankAssignmentCoordinator(adapter).assign({
userId: 8,
rank: 4,
mutate: async () => {
throw new Error("audit unavailable");
},
}),
).rejects.toThrow("audit unavailable");
expect(events.some((event) => event.startsWith("rcon:"))).toBe(false);
expect(events).not.toContain("transaction:1:commit");
});
beforeEach(() => {
vi.clearAllMocks();
});
it("commits rank then user before starting delivery and holds the delivery lock through RCON", async () => {
const { adapter, events } = adapterFixture();
const coordinator = createRankAssignmentCoordinator(adapter);
const result = await coordinator.assign({
userId: 8,
rank: 4,
mutate: async () => {
events.push("mutation:audit");
},
});
expect(result).toEqual({ status: "delivered", rank: 5 });
expect(events).toEqual([
"transaction:1:start",
"rank:4:for-update",
"user:8:for-update",
"user:8:update:4",
"mutation:audit",
"transaction:1:commit",
"transaction:2:start",
"user:8:for-update",
"rcon:8:5",
"transaction:2:commit",
]);
});
it("propagates rank-lock dependency failures instead of converting them to not found", async () => {
const dependencyFailure = new Error("lock wait timeout");
const { adapter, events } = adapterFixture({
lockRank: async () => {
throw dependencyFailure;
},
});
const coordinator = createRankAssignmentCoordinator(adapter);
await expect(coordinator.assign({ userId: 8, rank: 4 })).rejects.toBe(
dependencyFailure,
);
expect(events.some((event) => event.startsWith("rcon:"))).toBe(false);
});
it("distinguishes a missing configured rank from a dependency failure", async () => {
const { adapter } = adapterFixture({ lockRank: async () => false });
const coordinator = createRankAssignmentCoordinator(adapter);
await expect(
coordinator.assign({ userId: 8, rank: 4 }),
).rejects.toMatchObject({ code: "RANK_NOT_FOUND" });
});
it("reports a deleted user after commit as a superseded delivery", async () => {
const { adapter, events } = adapterFixture();
vi.mocked(adapter.lockUser)
.mockReset()
.mockImplementationOnce(async (_transaction, userId) => {
events.push(`user:${userId}:for-update`);
return { rank: 3 };
})
.mockImplementationOnce(async (_transaction, userId) => {
events.push(`user:${userId}:for-update`);
return null;
});
const coordinator = createRankAssignmentCoordinator(adapter);
const result = await coordinator.assign({ userId: 8, rank: 4 });
expect(result).toEqual({ status: "superseded" });
expect(events).not.toContain("rcon:8:4");
});
it("returns the current committed rank as pending when bounded delivery fails", async () => {
const { adapter } = adapterFixture({ deliverRank: async () => false });
const coordinator = createRankAssignmentCoordinator(adapter);
await expect(coordinator.assign({ userId: 8, rank: 4 })).resolves.toEqual({
status: "pending",
rank: 5,
});
});
});
+136
View File
@@ -0,0 +1,136 @@
import "server-only";
import { eq, sql } from "drizzle-orm";
import { type Db, db, User } from "@/lib/db";
import { rcon } from "@/lib/services/rcon";
export type RankAssignmentFailureCode = "RANK_NOT_FOUND" | "USER_NOT_FOUND";
export class RankAssignmentFailure extends Error {
constructor(readonly code: RankAssignmentFailureCode) {
super(code);
this.name = "RankAssignmentFailure";
}
}
export type RankDeliveryResult =
| { readonly status: "delivered" | "pending"; readonly rank: number }
| { readonly status: "superseded" };
export interface RankAssignmentOptions<TTransaction = unknown> {
readonly userId: number;
readonly rank: number;
readonly authorize?: (
currentRank: number,
transaction: TTransaction,
) => Promise<void> | void;
readonly mutate?: (
transaction: TTransaction,
previousRank: number,
) => Promise<void> | void;
}
export interface RankAssignmentAdapter<TTransaction = unknown> {
transaction<T>(run: (transaction: TTransaction) => Promise<T>): Promise<T>;
lockRank(transaction: TTransaction, rank: number): Promise<boolean>;
lockUser(
transaction: TTransaction,
userId: number,
): Promise<{ readonly rank: number } | null>;
updateUserRank(
transaction: TTransaction,
userId: number,
rank: number,
): Promise<void>;
deliverRank(userId: number, rank: number): Promise<boolean>;
}
export interface RankAssignmentCoordinator<TTransaction = unknown> {
assign(
options: RankAssignmentOptions<TTransaction>,
): Promise<RankDeliveryResult>;
commit(options: RankAssignmentOptions<TTransaction>): Promise<number>;
synchronize(userId: number): Promise<RankDeliveryResult>;
}
export function createRankAssignmentCoordinator<TTransaction>(
adapter: RankAssignmentAdapter<TTransaction>,
): RankAssignmentCoordinator<TTransaction> {
const commit = async (
options: RankAssignmentOptions<TTransaction>,
): Promise<number> =>
adapter.transaction(async (transaction) => {
if (!(await adapter.lockRank(transaction, options.rank))) {
throw new RankAssignmentFailure("RANK_NOT_FOUND");
}
const target = await adapter.lockUser(transaction, options.userId);
if (!target) throw new RankAssignmentFailure("USER_NOT_FOUND");
await options.authorize?.(target.rank, transaction);
await adapter.updateUserRank(transaction, options.userId, options.rank);
await options.mutate?.(transaction, target.rank);
return target.rank;
});
const synchronize = async (userId: number): Promise<RankDeliveryResult> =>
adapter.transaction(async (transaction) => {
const target = await adapter.lockUser(transaction, userId);
if (!target) return { status: "superseded" };
let delivered = false;
try {
delivered = await adapter.deliverRank(userId, target.rank);
} catch {
// The committed database rank remains the source of truth for retry.
}
return {
status: delivered ? "delivered" : "pending",
rank: target.rank,
};
});
return {
commit,
synchronize,
async assign(options) {
await commit(options);
return synchronize(options.userId);
},
};
}
export type RankAssignmentTransaction = Pick<
Db,
"execute" | "insert" | "select" | "update"
>;
/** Rank writers and rank deletion acquire this lock before touching users. */
export async function lockConfiguredRank(
transaction: Pick<Db, "execute">,
rank: number,
): Promise<boolean> {
const [rows] = await transaction.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1 FOR UPDATE`,
);
return (rows as unknown as unknown[]).length > 0;
}
const productionRankAssignmentAdapter: RankAssignmentAdapter<RankAssignmentTransaction> =
{
transaction: (run) => db.transaction((transaction) => run(transaction)),
lockRank: lockConfiguredRank,
async lockUser(transaction, userId) {
const [target] = await transaction
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, userId))
.for("update");
return target ?? null;
},
async updateUserRank(transaction, userId, rank) {
await transaction.update(User).set({ rank }).where(eq(User.id, userId));
},
deliverRank: (userId, rank) => rcon.setRank(userId, rank),
};
export const rankAssignmentCoordinator = createRankAssignmentCoordinator(
productionRankAssignmentAdapter,
);
+139
View File
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const doubles = vi.hoisted(() => ({
people: vi.fn(),
system: vi.fn(),
revalidate: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/safe-action", () => ({
adminAction: (_options: unknown, handler: unknown) => handler,
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (_options: unknown, handler: unknown) => handler,
}));
vi.mock("next/cache", async (importOriginal) => ({
...(await importOriginal<typeof import("next/cache")>()),
revalidatePath: doubles.revalidate,
revalidateTag: doubles.revalidate,
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
peopleMutationService: { execute: doubles.people },
}));
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
systemMutationService: { execute: doubles.system },
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
import { setRank } from "@/actions/commandocentrum";
import { deleteRank } from "@/actions/permissions";
import { updateUser } from "@/actions/users";
import { POST } from "@/app/api/admin/users/actions/route";
const context = {
session: { user: { id: 42, username: "operator", rank: 6 } },
permissions: {
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
},
};
const partial = {
ok: true,
data: {},
correlationId: "recovery-42",
completion: { status: "partial", external: "failed", audit: "persisted" },
};
beforeEach(() => {
vi.clearAllMocks();
doubles.people.mockResolvedValue({
ok: true,
data: {},
correlationId: "operation-42",
});
doubles.system.mockResolvedValue({
ok: true,
data: { rank: 4 },
correlationId: "operation-42",
});
});
describe("legacy rank entrypoints", () => {
it("delegates legacy rank deletion to the same System transaction", async () => {
await deleteRank({ ...context, data: { id: 4 } } as never);
expect(doubles.system).toHaveBeenCalledWith(
expect.objectContaining({
capability: expect.objectContaining({ actor: context.session.user }),
}),
"access.rank.delete",
{ id: 4 },
);
});
it("routes command-centre assignments through the authorized System service", async () => {
await setRank({ ...context, data: { userId: 8, rank: 4 } } as never);
expect(doubles.system).toHaveBeenCalledWith(
expect.objectContaining({
capability: expect.objectContaining({ actor: context.session.user }),
correlationId: expect.any(String),
}),
"rcon.set-rank",
{ userId: 8, rank: 4 },
);
});
it("does not present a partially synchronized command-centre assignment as complete", async () => {
doubles.system.mockResolvedValue(partial);
await expect(
setRank({ ...context, data: { userId: 8, rank: 4 } } as never),
).rejects.toThrow("Rank saved");
});
it("routes the old user editor through People, preserving fields and actor identity", async () => {
await updateUser({
...context,
data: { id: 8, rank: 4, motto: "Updated" },
} as never);
expect(doubles.people).toHaveBeenCalledWith(
expect.objectContaining({
expectedActorId: 42,
correlationId: expect.any(String),
}),
"user.update",
{ userId: 8, fields: { rank: 4, motto: "Updated" } },
);
});
it("surfaces committed partial completion in the old user editor", async () => {
doubles.people.mockResolvedValue(partial);
await expect(
updateUser({ ...context, data: { id: 8, rank: 4 } } as never),
).rejects.toThrow("User saved");
});
it("returns 202 and completion metadata when the user API committed but delivery is pending", async () => {
doubles.people.mockResolvedValue(partial);
const request = new Request("http://localhost/api/admin/users/actions", {
method: "POST",
body: new URLSearchParams({
action: "set_rank",
userId: "8",
username: "Alice",
rank: "4",
}),
});
const response = await POST(request as never, context as never);
expect(response.status).toBe(202);
expect(await response.json()).toMatchObject({
success: true,
correlationId: "recovery-42",
completion: partial.completion,
});
expect(doubles.people).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 42 }),
"user.update",
{ userId: 8, fields: { rank: 4 } },
);
});
});