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

@@ -0,0 +1,52 @@
# Housekeeping rank synchronization
## Behavior
Rank assignments commit the configured rank, user update, and audit intent before
attempting emulator synchronization. Assignment and rank deletion acquire the
configured-rank row lock first. Delivery acquires the user row lock, reads the
current database rank, and holds that lock until the transport settles.
Retrying an old recovery reference therefore dispatches the current committed
rank rather than replaying the rank stored in the old audit record. A user deleted
after persistence produces an audited superseded result. SQL lock errors remain
dependency failures rather than being reported as missing ranks.
The coordinated paths cover System operations, People user editing, the legacy
command centre, legacy user editing, the user-actions API, legacy rank deletion,
and shop rank upgrades. Administrative user creation also locks the selected rank.
Shop upgrades compare the locked current rank so they cannot overwrite a newer
staff promotion. A failed post-purchase rank delivery leaves a recovery intent
without turning the completed purchase into another charge.
People and legacy responses preserve partial-completion information and recovery
references. Failure of the completion audit alone does not misreport successful
transport delivery as a transport failure.
## Verification
- Focused rank, legacy-entrypoint, shop, System and People tests: 94 passed.
- Full suite: 280 files passed, 3 skipped; 1,861 tests passed, 5 skipped.
- Next.js 16.3.4 production build passed and generated all 245 pages.
- TypeScript and canonical Knip checks passed.
- Biome passed on all 13 changed source/test files.
- Project-source lint without formatting passed on 1,412 files, with one existing
Catalog Studio warning. The full Windows checkout check also includes local
untracked brainstorm HTML and reports CRLF/LF formatting differences; those
local files were preserved and are not part of this change.
- Migration matrix: 138 historical rows valid; 138 legacy pages retained;
runtime discovered/mapped/verified 138/138/138, with 2 intentional removals.
- Independent read-only review found no remaining Important or Critical issues.
## Validation boundary
Tests exercise the parameterized locking SQL and controlled transaction/transport
ordering. No live two-connection MariaDB race test or production emulator
acceptance test was performed.
TCP RCON success means the socket write completed. CMS dispatch is serialized,
but the current protocol does not acknowledge emulator processing or enforce its
processing order. End-to-end confirmation would require an emulator protocol
change. The existing transport timeout and retry policy bounds the delivery wait.
The PR remains draft; these checks are not a deployment or preview-cutover claim.
+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 } },
);
});
});