fix(housekeeping): coordinate all rank mutation paths
This commit is contained in:
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.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq, sql } from "drizzle-orm";
|
import crypto from "node:crypto";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
@@ -198,44 +199,28 @@ const setRankSchema = z.object({
|
|||||||
export const setRank = adminAction(
|
export const setRank = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const staffRank = Number(ctx.session.user.rank);
|
const result = await systemMutationService.execute(
|
||||||
const isSuper = ctx.permissions.isSuperAdmin;
|
{
|
||||||
const [target] = await db
|
capability: createHousekeepingCapabilityContext(
|
||||||
.select({ rank: User.rank })
|
{
|
||||||
.from(User)
|
id: Number(ctx.session.user.id),
|
||||||
.where(eq(User.id, ctx.data.userId))
|
username: ctx.session.user.username,
|
||||||
.limit(1);
|
rank: Number(ctx.session.user.rank),
|
||||||
if (!target) throw new ActionError("User not found");
|
},
|
||||||
|
ctx.permissions,
|
||||||
let rankExists: { id: number }[] = [];
|
),
|
||||||
try {
|
correlationId: crypto.randomUUID(),
|
||||||
const [rows] = await db.execute(
|
},
|
||||||
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
|
"rcon.set-rank",
|
||||||
);
|
ctx.data,
|
||||||
rankExists = rows as unknown as { id: number }[];
|
);
|
||||||
} catch {
|
if (!result.ok) throw new ActionError(result.error.messageKey);
|
||||||
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));
|
|
||||||
revalidatePath(PATH);
|
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
@@ -1,23 +1,18 @@
|
|||||||
"use server";
|
"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 type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
||||||
AclModelPermission,
|
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||||
AclModelRole,
|
import { AclModelPermission, AclPermission, AclRole, db } from "@/lib/db";
|
||||||
AclPermission,
|
|
||||||
AclRole,
|
|
||||||
db,
|
|
||||||
User,
|
|
||||||
} from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import {
|
import {
|
||||||
createEmulatorRank,
|
createEmulatorRank,
|
||||||
deleteEmulatorRank,
|
|
||||||
updateEmulatorRank,
|
updateEmulatorRank,
|
||||||
} from "@/lib/services/permission-ranks";
|
} from "@/lib/services/permission-ranks";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -58,42 +53,27 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteRank = adminAction(
|
export const deleteRank = adminAction(
|
||||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [userCount] = await db
|
const result = await systemMutationService.execute(
|
||||||
.select({ total: count() })
|
{
|
||||||
.from(User)
|
capability: createHousekeepingCapabilityContext(
|
||||||
.where(eq(User.rank, ctx.data.id));
|
{
|
||||||
const users = userCount?.total ?? 0;
|
id: Number(ctx.session.user.id),
|
||||||
if (users > 0)
|
username: ctx.session.user.username,
|
||||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
rank: Number(ctx.session.user.rank),
|
||||||
const [role] = await db
|
},
|
||||||
.select({ id: AclRole.id })
|
ctx.permissions,
|
||||||
.from(AclRole)
|
),
|
||||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
correlationId: crypto.randomUUID(),
|
||||||
.limit(1);
|
},
|
||||||
await deleteEmulatorRank(db, ctx.data.id);
|
"access.rank.delete",
|
||||||
if (role) {
|
ctx.data,
|
||||||
await db.transaction(async (tx) => {
|
);
|
||||||
await tx
|
if (!result.ok) throw new ActionError(result.error.messageKey);
|
||||||
.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");
|
|
||||||
revalidateTag("permissions", { expire: 0 });
|
revalidateTag("permissions", { expire: 0 });
|
||||||
|
if (result.completion)
|
||||||
|
throw new ActionError(
|
||||||
|
`Rank deleted; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`,
|
||||||
|
);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
import { and, eq, max, sql } from "drizzle-orm";
|
import { and, eq, max, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -7,7 +8,12 @@ import { auth } from "@/lib/auth";
|
|||||||
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||||
|
import {
|
||||||
|
lockConfiguredRank,
|
||||||
|
rankAssignmentCoordinator,
|
||||||
|
} from "@/lib/services/rank-assignment";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||||
|
|
||||||
@@ -118,8 +124,38 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
|||||||
outcome = "credits";
|
outcome = "credits";
|
||||||
} else {
|
} else {
|
||||||
const badgeCodes = parseBadgeCodes(article.badges);
|
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) => {
|
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) {
|
if (price > 0) {
|
||||||
await tx
|
await tx
|
||||||
.update(User)
|
.update(User)
|
||||||
@@ -130,12 +166,14 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
|||||||
if (
|
if (
|
||||||
article.giveRank != null &&
|
article.giveRank != null &&
|
||||||
article.giveRank > 0 &&
|
article.giveRank > 0 &&
|
||||||
article.giveRank > buyer.rank
|
article.giveRank > lockedBuyer.rank
|
||||||
) {
|
) {
|
||||||
await tx
|
await tx
|
||||||
.update(User)
|
.update(User)
|
||||||
.set({ rank: article.giveRank })
|
.set({ rank: article.giveRank })
|
||||||
.where(eq(User.id, userId));
|
.where(eq(User.id, userId));
|
||||||
|
rankChanged = true;
|
||||||
|
await logAudit({ ...rankAudit, outcome: "intent" }, tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const code of badgeCodes) {
|
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(
|
await sendCurrency(
|
||||||
{ rcon, db: currencyDb },
|
{ rcon, db: currencyDb },
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
+19
-60
@@ -3,6 +3,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { invalidateLoginCache } from "@/lib/auth";
|
import { invalidateLoginCache } from "@/lib/auth";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { lockConfiguredRank } from "@/lib/services/rank-assignment";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { notify } from "@/lib/services/webhook";
|
import { notify } from "@/lib/services/webhook";
|
||||||
import {
|
import {
|
||||||
@@ -55,7 +57,7 @@ export const createUser = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { username, mail, password, rank, motto } = ctx.data;
|
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");
|
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,9 @@ export const createUser = adminAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await db.transaction(async (tx) => {
|
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({
|
const [result] = await tx.insert(User).values({
|
||||||
username,
|
username,
|
||||||
mail,
|
mail,
|
||||||
@@ -123,66 +128,20 @@ const updateUserInput = updateUserSchema.extend({
|
|||||||
export const updateUser = adminAction(
|
export const updateUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, diamonds, duckets, ...userData } = ctx.data;
|
const { id, ...fields } = ctx.data;
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
const targetUser = await guardRank(id, ctx.session.user.rank);
|
{
|
||||||
|
correlationId: crypto.randomUUID(),
|
||||||
if (
|
expectedActorId: Number(ctx.session.user.id),
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
after: userData,
|
"user.update",
|
||||||
});
|
{ userId: id, fields },
|
||||||
|
);
|
||||||
notify({
|
if (!result.ok) throw new ActionError(result.error.messageKey);
|
||||||
action: "user_edit",
|
if (result.completion)
|
||||||
actor: ctx.session.user.username,
|
throw new ActionError(
|
||||||
target: targetUser.username,
|
`User saved; synchronization or completion audit is pending. Reference: ${result.correlationId}`,
|
||||||
targetId: id,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import crypto from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { db, User } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -10,7 +10,6 @@ export const POST = withAdmin(
|
|||||||
{ permission: PERMS.USERS_EDIT },
|
{ permission: PERMS.USERS_EDIT },
|
||||||
async (request, context) => {
|
async (request, context) => {
|
||||||
const staffId = context.session.user.id;
|
const staffId = context.session.user.id;
|
||||||
const staffRank = context.session.user.rank;
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const username = String(formData.get("username") || "");
|
const username = String(formData.get("username") || "");
|
||||||
@@ -32,68 +31,38 @@ export const POST = withAdmin(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rankExists] = (await db
|
const result = await peopleMutationService.execute(
|
||||||
.execute(
|
{
|
||||||
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
|
correlationId: crypto.randomUUID(),
|
||||||
)
|
expectedActorId: Number(staffId),
|
||||||
.catch(() => [[]] as unknown as [unknown[], unknown])) as unknown as [
|
},
|
||||||
{ id: number }[],
|
"user.update",
|
||||||
unknown,
|
{ userId, fields: { rank } },
|
||||||
];
|
);
|
||||||
if (rankExists.length === 0) {
|
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(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Rank does not exist" },
|
{ success: false, message: result.error.messageKey },
|
||||||
{ status: 400 },
|
{ 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(
|
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-2
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
kickAll: vi.fn(),
|
kickAll: vi.fn(),
|
||||||
muteUser: vi.fn(),
|
muteUser: vi.fn(),
|
||||||
setTradeLock: vi.fn(),
|
setTradeLock: vi.fn(),
|
||||||
|
setRank: vi.fn(),
|
||||||
staffAlert: vi.fn(),
|
staffAlert: vi.fn(),
|
||||||
unmuteUser: vi.fn(),
|
unmuteUser: vi.fn(),
|
||||||
updateWordFilter: vi.fn(),
|
updateWordFilter: vi.fn(),
|
||||||
@@ -46,6 +47,7 @@ function mutationPromise(
|
|||||||
function selectResult() {
|
function selectResult() {
|
||||||
const value = mocks.selectQueue.shift() ?? [];
|
const value = mocks.selectQueue.shift() ?? [];
|
||||||
return Object.assign(Promise.resolve(value), {
|
return Object.assign(Promise.resolve(value), {
|
||||||
|
for: vi.fn(async () => value),
|
||||||
limit: vi.fn(async () => value),
|
limit: vi.fn(async () => value),
|
||||||
orderBy: vi.fn(() =>
|
orderBy: vi.fn(() =>
|
||||||
Object.assign(Promise.resolve(value), {
|
Object.assign(Promise.resolve(value), {
|
||||||
@@ -173,6 +175,36 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("People production workflow adapter", () => {
|
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 () => {
|
it("does not treat a high numeric rank as a super-admin hierarchy bypass", async () => {
|
||||||
mocks.resolveServerContext.mockResolvedValue({
|
mocks.resolveServerContext.mockResolvedValue({
|
||||||
...context(),
|
...context(),
|
||||||
@@ -231,7 +263,8 @@ describe("People production workflow adapter", () => {
|
|||||||
fieldErrors: { rank: ["errors.validation.invalid"] },
|
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 () => {
|
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.selectQueue.push([target({ rank: 2 })]);
|
||||||
mocks.execute.mockResolvedValueOnce([[{ id: 9 }], []]);
|
mocks.execute.mockResolvedValueOnce([[{ id: 9 }], []]);
|
||||||
|
mocks.selectQueue.push([target({ rank: 2 })], [target({ rank: 9 })]);
|
||||||
|
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
invocation,
|
invocation,
|
||||||
@@ -253,7 +287,8 @@ describe("People production workflow adapter", () => {
|
|||||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ rank: 9 }),
|
expect.objectContaining({ rank: 9 }),
|
||||||
);
|
);
|
||||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
expect(mocks.transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.rcon.setRank).toHaveBeenCalledWith(7, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ import {
|
|||||||
executeModerationAction,
|
executeModerationAction,
|
||||||
reloadWordFilter,
|
reloadWordFilter,
|
||||||
} from "@/lib/services/moderation";
|
} from "@/lib/services/moderation";
|
||||||
|
import {
|
||||||
|
RankAssignmentFailure,
|
||||||
|
type RankAssignmentTransaction,
|
||||||
|
rankAssignmentCoordinator,
|
||||||
|
} from "@/lib/services/rank-assignment";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
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) {
|
function targetSnapshot(target: TargetUser) {
|
||||||
return {
|
return {
|
||||||
id: target.id,
|
id: target.id,
|
||||||
@@ -642,22 +636,39 @@ async function executeUserMutation(
|
|||||||
"errors.housekeeping.forbidden",
|
"errors.housekeeping.forbidden",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await assertConfiguredRankExists(rank);
|
|
||||||
}
|
}
|
||||||
const userPatch = Object.fromEntries(
|
const userPatch = Object.fromEntries(
|
||||||
["username", "mail", "rank", "motto", "credits", "pixels"].flatMap(
|
["username", "mail", "motto", "credits", "pixels"].flatMap((key) =>
|
||||||
(key) => (fields[key] === undefined ? [] : [[key, fields[key]]]),
|
fields[key] === undefined ? [] : [[key, fields[key]]],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const safeFields = Object.fromEntries(
|
const safeFields = Object.fromEntries(
|
||||||
Object.entries(fields).filter(([key]) => key !== "mail"),
|
Object.entries(fields).filter(([key]) => key !== "mail"),
|
||||||
);
|
);
|
||||||
if (fields.mail !== undefined) safeFields.mailChanged = true;
|
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 = {
|
const snapshot: PeopleMutationSnapshot = {
|
||||||
before,
|
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) {
|
if (Object.keys(userPatch).length > 0) {
|
||||||
await tx.update(User).set(userPatch).where(eq(User.id, userId));
|
await tx.update(User).set(userPatch).where(eq(User.id, userId));
|
||||||
}
|
}
|
||||||
@@ -683,8 +694,49 @@ async function executeUserMutation(
|
|||||||
),
|
),
|
||||||
tx,
|
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,
|
context,
|
||||||
operation,
|
operation,
|
||||||
"User",
|
"User",
|
||||||
@@ -692,6 +744,33 @@ async function executeUserMutation(
|
|||||||
snapshot,
|
snapshot,
|
||||||
async () => {
|
async () => {
|
||||||
invalidateLoginCache(target.username);
|
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({
|
void notify({
|
||||||
action: "user_edit",
|
action: "user_edit",
|
||||||
actor: context.capability.actor.username,
|
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") {
|
if (operation === "user.ban") {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import {
|
|||||||
prepareEmulatorRankCreation,
|
prepareEmulatorRankCreation,
|
||||||
updateEmulatorRank,
|
updateEmulatorRank,
|
||||||
} from "@/lib/services/permission-ranks";
|
} from "@/lib/services/permission-ranks";
|
||||||
|
import {
|
||||||
|
lockConfiguredRank,
|
||||||
|
RankAssignmentFailure,
|
||||||
|
rankAssignmentCoordinator,
|
||||||
|
} from "@/lib/services/rank-assignment";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import {
|
import {
|
||||||
@@ -164,6 +169,15 @@ export function createSystemMutationService(
|
|||||||
context.correlationId,
|
context.correlationId,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof SystemCommittedExternalFailure) {
|
||||||
return ok(error.data, context.correlationId, {
|
return ok(error.data, context.correlationId, {
|
||||||
status: "partial",
|
status: "partial",
|
||||||
@@ -301,7 +315,11 @@ function synchronizationData(
|
|||||||
synchronization,
|
synchronization,
|
||||||
completed:
|
completed:
|
||||||
synchronization === "completed"
|
synchronization === "completed"
|
||||||
? ["database", "audit", pendingExternal(intent)]
|
? [
|
||||||
|
"database",
|
||||||
|
"audit",
|
||||||
|
extra.superseded ? "target-deleted" : pendingExternal(intent),
|
||||||
|
]
|
||||||
: ["database", "audit"],
|
: ["database", "audit"],
|
||||||
pending: synchronization === "completed" ? [] : [pendingExternal(intent)],
|
pending: synchronization === "completed" ? [] : [pendingExternal(intent)],
|
||||||
};
|
};
|
||||||
@@ -312,6 +330,7 @@ async function executeExternalSynchronization(
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
readonly intent: SystemExternalSyncIntent;
|
readonly intent: SystemExternalSyncIntent;
|
||||||
readonly synchronized: boolean;
|
readonly synchronized: boolean;
|
||||||
|
readonly superseded?: boolean;
|
||||||
}> {
|
}> {
|
||||||
if (intent.kind !== "set-rank") {
|
if (intent.kind !== "set-rank") {
|
||||||
return {
|
return {
|
||||||
@@ -320,34 +339,14 @@ async function executeExternalSynchronization(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
const delivery = await rankAssignmentCoordinator.synchronize(intent.userId);
|
||||||
const [target] = await tx
|
if (delivery.status === "superseded") {
|
||||||
.select({ rank: User.rank })
|
return { intent, synchronized: true, superseded: true };
|
||||||
.from(User)
|
}
|
||||||
.where(eq(User.id, intent.userId))
|
return {
|
||||||
.for("update");
|
intent: { ...intent, rank: delivery.rank },
|
||||||
if (!target) {
|
synchronized: delivery.status === "delivered",
|
||||||
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 };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeExternalSynchronization(
|
async function completeExternalSynchronization(
|
||||||
@@ -358,10 +357,12 @@ async function completeExternalSynchronization(
|
|||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
let currentIntent = intent;
|
let currentIntent = intent;
|
||||||
let synchronized = false;
|
let synchronized = false;
|
||||||
|
let superseded = false;
|
||||||
try {
|
try {
|
||||||
const execution = await executeExternalSynchronization(intent);
|
const execution = await executeExternalSynchronization(intent);
|
||||||
currentIntent = execution.intent;
|
currentIntent = execution.intent;
|
||||||
synchronized = execution.synchronized;
|
synchronized = execution.synchronized;
|
||||||
|
superseded = execution.superseded ?? false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SystemMutationFailure) throw error;
|
if (error instanceof SystemMutationFailure) throw error;
|
||||||
}
|
}
|
||||||
@@ -382,13 +383,18 @@ async function completeExternalSynchronization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await logAudit(
|
const entry = syncAuditEntry(currentIntent, context, recoveryId, "success");
|
||||||
syncAuditEntry(currentIntent, context, recoveryId, "success"),
|
await logAudit({
|
||||||
);
|
...entry,
|
||||||
|
after: { ...entry.after, ...(superseded ? { superseded: true } : {}) },
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new SystemCommittedExternalFailure(
|
throw new SystemCommittedExternalFailure(
|
||||||
{
|
{
|
||||||
...synchronizationData(currentIntent, recoveryId, "completed", extra),
|
...synchronizationData(currentIntent, recoveryId, "completed", {
|
||||||
|
...extra,
|
||||||
|
...(superseded ? { superseded: true } : {}),
|
||||||
|
}),
|
||||||
pending: ["completion-audit"],
|
pending: ["completion-audit"],
|
||||||
},
|
},
|
||||||
"completed",
|
"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(
|
function parseExternalSyncIntent(
|
||||||
@@ -570,16 +579,7 @@ async function executeAccessMutation(
|
|||||||
rankId: id,
|
rankId: id,
|
||||||
} as const satisfies SystemExternalSyncIntent;
|
} as const satisfies SystemExternalSyncIntent;
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
let rankRows: { id: number }[] = [];
|
if (!(await lockConfiguredRank(tx, id))) {
|
||||||
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) {
|
|
||||||
throw new SystemMutationFailure(
|
throw new SystemMutationFailure(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
"errors.housekeeping.system.rankNotFound",
|
"errors.housekeeping.system.rankNotFound",
|
||||||
@@ -978,63 +978,40 @@ async function executeRconMutation(
|
|||||||
userId,
|
userId,
|
||||||
rank,
|
rank,
|
||||||
} as const satisfies SystemExternalSyncIntent;
|
} as const satisfies SystemExternalSyncIntent;
|
||||||
await db.transaction(async (tx) => {
|
await rankAssignmentCoordinator.commit({
|
||||||
let rankRows: { id: number }[] = [];
|
userId,
|
||||||
try {
|
rank,
|
||||||
const [rows] = await tx.execute(
|
authorize(currentRank) {
|
||||||
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1 FOR UPDATE`,
|
if (context.capability.isSuperAdmin) return;
|
||||||
);
|
if (currentRank >= context.capability.actor.rank) {
|
||||||
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) {
|
|
||||||
throw new SystemMutationFailure(
|
throw new SystemMutationFailure(
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"errors.housekeeping.system.cannotChangePeerRank",
|
"errors.housekeeping.system.cannotChangePeerRank",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (rank >= actorRank) {
|
if (rank >= context.capability.actor.rank) {
|
||||||
throw new SystemMutationFailure(
|
throw new SystemMutationFailure(
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"errors.housekeeping.system.cannotAssignPeerRank",
|
"errors.housekeeping.system.cannotAssignPeerRank",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
await tx.update(User).set({ rank }).where(eq(User.id, userId));
|
async mutate(tx) {
|
||||||
await logStaffActivityInTransaction(
|
await logStaffActivityInTransaction(
|
||||||
{
|
{
|
||||||
staffId: context.capability.actor.id,
|
staffId: context.capability.actor.id,
|
||||||
action: "rank_assign",
|
action: "rank_assign",
|
||||||
description: `Assigned rank #${rank} to user #${userId}`,
|
description: `Assigned rank #${rank} to user #${userId}`,
|
||||||
targetType: "user",
|
targetType: "user",
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
await logAudit(
|
await logAudit(
|
||||||
syncAuditEntry(intent, context, context.correlationId, "intent"),
|
syncAuditEntry(intent, context, context.correlationId, "intent"),
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return completeExternalSynchronization(intent, context, undefined, {
|
return completeExternalSynchronization(intent, context, undefined, {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -186,6 +186,47 @@ function expectPendingSynchronization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("durable rank synchronization", () => {
|
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 () => {
|
it("rolls back before RCON when the transaction-bound recovery intent cannot be audited", async () => {
|
||||||
doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable"));
|
doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable"));
|
||||||
doubles.rconSend.mockResolvedValue(true);
|
doubles.rconSend.mockResolvedValue(true);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -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 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user