feat(housekeeping): make system mutations atomic

This commit is contained in:
Simo committed 2026-09-05 17:02:12 +02:00
1 parent 874cb2beb2
commit 1360b0ed59
21 files changed
+1032 -544

No files matched your search

+12 -4
View File
@@ -2,9 +2,13 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
import { sendHotelAlert } from "./admin-alerts";
const { executeSystem } = vi.hoisted(() => ({ executeSystem: vi.fn() }));
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
executeLegacySystemMutation: executeSystem,
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
@@ -15,7 +19,6 @@ vi.mock("@/lib/db", () => ({
},
AlertLogs: {},
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const fakeForm = (data: Record<string, string>) => ({
@@ -24,6 +27,7 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
executeSystem.mockResolvedValue({ delivered: true });
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
@@ -36,12 +40,16 @@ describe("sendHotelAlert", () => {
await sendHotelAlert(
fakeForm({ message: "Hello!" }) as unknown as FormData,
);
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
expect(executeSystem).toHaveBeenCalledWith(
{ id: 1, rank: 7, username: "admin" },
"operations.alert.broadcast",
{ message: "Hello!" },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
});
it("returns early when message is empty", async () => {
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
expect(rcon.send).not.toHaveBeenCalled();
expect(executeSystem).not.toHaveBeenCalled();
});
});
+7 -19
View File
@@ -1,11 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { AlertLogs, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
/**
* Broadcast a hotel-wide alert to every online user via RCON.
@@ -14,7 +12,7 @@ import { rcon } from "@/lib/services/rcon";
* `message` payload. Staff-gated; the message is trimmed/bounded before send.
*/
export async function sendHotelAlert(formData: FormData): Promise<void> {
await requirePermission(PERMS.NOTIFICATIONS_EDIT);
const actor = await requirePermission(PERMS.NOTIFICATIONS_EDIT);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
@@ -22,26 +20,16 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
.slice(0, 1000);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
} catch {
// Best-effort delivery (dead socket / emulator offline) — never 500 the
// admin page. The emulator writes its own alert_logs row on receipt.
}
await executeLegacySystemMutation(actor, "operations.alert.broadcast", {
message,
});
revalidatePath("/admin/alerts");
}
/** Mark every unread ops alert as read. */
export async function markAllAlertsRead(): Promise<void> {
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
try {
await db
.update(AlertLogs)
.set({ isRead: true, updatedAt: new Date() })
.where(eq(AlertLogs.isRead, false));
} catch {
/* ignore */
}
const actor = await requirePermission(PERMS.NOTIFICATIONS_VIEW);
await executeLegacySystemMutation(actor, "operations.alerts.mark-read", {});
revalidatePath("/admin/alerts");
}
+13 -11
View File
@@ -1,8 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
@@ -11,7 +11,7 @@ import { PERMS } from "@/lib/permissions";
// keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const actor = await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
@@ -20,15 +20,16 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.slice(0, 512);
if (!key) return;
await db
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await executeLegacySystemMutation(
actor,
"configuration.emulator-setting.update",
{ key, value },
);
revalidatePath("/admin/emulator");
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const actor = await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
@@ -37,9 +38,10 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
.normalize("NFC")
.slice(0, 4096);
if (!key) return;
await db
.insert(EmulatorTexts)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await executeLegacySystemMutation(
actor,
"configuration.emulator-text.update",
{ key, value },
);
revalidatePath("/admin/emulator");
}
+22 -68
View File
@@ -1,21 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
mockValues,
mockOnDuplicateKeyUpdate,
mockRequirePermission,
mockReload,
mockRevalidatePath,
} = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
const { mockExecuteSystem, mockRequirePermission, mockRevalidatePath } =
vi.hoisted(() => {
return {
mockValues,
mockOnDuplicateKeyUpdate,
mockExecuteSystem: vi.fn(),
mockRequirePermission: vi.fn(),
mockReload: vi.fn(),
mockRevalidatePath: vi.fn(),
};
});
@@ -28,21 +17,14 @@ vi.mock("@/lib/permissions", () => ({
},
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
executeLegacySystemMutation: mockExecuteSystem,
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: mockRequirePermission,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: mockReload },
}));
vi.mock("next/cache", () => ({
revalidatePath: mockRevalidatePath,
}));
@@ -51,10 +33,7 @@ import { saveMaintenance } from "./admin-maintenance";
beforeEach(() => {
vi.clearAllMocks();
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
mockExecuteSystem.mockResolvedValue(null);
});
describe("saveMaintenance", () => {
@@ -74,28 +53,12 @@ describe("saveMaintenance", () => {
expect(mockRequirePermission).toHaveBeenCalled();
expect(mockValues).toHaveBeenCalledTimes(3);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "maintenance_enabled",
value: "1",
}),
expect(mockExecuteSystem).toHaveBeenCalledWith(
{ id: 1, rank: 7, username: "admin" },
"operations.maintenance.update",
{ enabled: true, message: "We will be back soon!", minimumLoginRank: 3 },
);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "maintenance_message",
value: "We will be back soon!",
}),
);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "3",
}),
);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
});
@@ -112,17 +75,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "maintenance_enabled",
value: "0",
}),
);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "5",
}),
expect(mockExecuteSystem).toHaveBeenCalledWith(
expect.anything(),
"operations.maintenance.update",
expect.objectContaining({ enabled: false, minimumLoginRank: 5 }),
);
});
@@ -140,11 +96,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "5",
}),
expect(mockExecuteSystem).toHaveBeenCalledWith(
expect.anything(),
"operations.maintenance.update",
expect.objectContaining({ minimumLoginRank: 5 }),
);
});
@@ -162,11 +117,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "5",
}),
expect(mockExecuteSystem).toHaveBeenCalledWith(
expect.anything(),
"operations.maintenance.update",
expect.objectContaining({ minimumLoginRank: 5 }),
);
});
+7 -32
View File
@@ -1,10 +1,9 @@
"use server";
import { revalidatePath } from "next/cache";
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
// AtomCMS's MaintenanceToggle Livewire component):
@@ -14,32 +13,8 @@ import { siteSettings } from "@/lib/services/site-settings";
// The Laravel login flow reads these via setting() to gate non-staff logins
// while maintenance is on, so the website_settings keys are the source of truth.
const KEY_ENABLED = "maintenance_enabled";
const KEY_MESSAGE = "maintenance_message";
const KEY_MIN_RANK = "min_maintenance_login_rank";
const COMMENTS: Record<string, string> = {
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
[KEY_MESSAGE]:
"The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]:
"The minimum rank required to login to the hotel during maintenance",
};
async function upsertSetting(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveMaintenance(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const actor = await requirePermission(PERMS.SETTINGS_EDIT);
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
// emulator/Laravel side expects.
@@ -56,10 +31,10 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
const minRank =
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
await upsertSetting(KEY_ENABLED, enabled);
await upsertSetting(KEY_MESSAGE, message);
await upsertSetting(KEY_MIN_RANK, String(minRank));
siteSettings.reload();
await executeLegacySystemMutation(actor, "operations.maintenance.update", {
enabled: enabled === "1",
message,
minimumLoginRank: minRank,
});
revalidatePath("/admin/maintenance");
}
+24 -69
View File
@@ -1,36 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action";
import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions";
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value);
}
return value;
}
function bustGamedataCachesIfNeeded(key: string): void {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
clearOfficialHabboFurnidataCache();
clearBadgeCache();
}
}
const saveManagedSchema = z.object({
settings: z.record(z.string(), z.string()),
@@ -44,79 +19,59 @@ export const saveManagedSettings = adminAction(
rateLimitMax: 30,
},
async (ctx) => {
const entries = Object.entries(ctx.data.settings)
.filter(([key]) => managedKeySet.has(key))
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
await Promise.all(
entries.map(([key, value]) =>
db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } }),
),
);
await siteSettings.reload();
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
clearOfficialHabboFurnidataCache();
clearBadgeCache();
}
const result = (await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"configuration.settings.save",
ctx.data,
)) as { saved: number };
revalidatePath("/admin/settings");
revalidatePath("/admin/catalog");
return actionOk({ saved: entries.length });
return actionOk({ saved: result.saved });
},
);
export async function updateSetting(formData: FormData): Promise<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const actor = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
const value = String(formData.get("value") ?? "").normalize("NFC");
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
await executeLegacySystemMutation(actor, "configuration.setting.update", {
key,
value,
});
revalidatePath("/admin/settings");
}
export async function createSetting(formData: FormData): Promise<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const actor = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
const value = String(formData.get("value") ?? "").normalize("NFC");
const comment = String(formData.get("comment") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
await executeLegacySystemMutation(actor, "configuration.setting.create", {
key,
value,
comment,
});
revalidatePath("/admin/settings");
}
export async function deleteSetting(formData: FormData): Promise<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const actor = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
if (!key) return;
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
await executeLegacySystemMutation(actor, "configuration.setting.delete", {
key,
});
revalidatePath("/admin/settings");
}
+56 -57
View File
@@ -1,28 +1,34 @@
"use server";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import {
executeLegacySystemMutation,
type SystemMutationOperation,
} from "@/features/housekeeping/domains/system/services/mutations";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { actionOk } from "@/lib/safe-action-shared";
const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?";
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
async function executeLegacyRcon(
ctx: { session: { user: { id: string | number } } },
operation: SystemMutationOperation,
input: unknown,
): Promise<unknown> {
return executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
operation,
input,
);
}
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export const updateCatalog = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateCatalog());
async (ctx) => {
await executeLegacyRcon(ctx, "rcon.update-catalog", {});
revalidatePath(PATH);
return actionOk();
},
@@ -31,8 +37,8 @@ export const updateCatalog = adminAction(
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export const updateWordFilter = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateWordFilter());
async (ctx) => {
await executeLegacyRcon(ctx, "rcon.update-word-filter", {});
revalidatePath(PATH);
return actionOk();
},
@@ -41,8 +47,8 @@ export const updateWordFilter = adminAction(
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export const updateNavigator = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.send("updatenavigator", null));
async (ctx) => {
await executeLegacyRcon(ctx, "rcon.update-navigator", {});
revalidatePath(PATH);
return actionOk();
},
@@ -57,7 +63,7 @@ export const hotelAlert = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.send("hotelalert", { message }));
await executeLegacyRcon(ctx, "rcon.hotel-alert", { message });
revalidatePath(PATH);
return actionOk();
},
@@ -73,7 +79,10 @@ export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
const username = ctx.data.username.normalize("NFC");
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
await executeLegacyRcon(ctx, "rcon.disconnect-user", {
userId: ctx.data.userId,
username,
});
revalidatePath(PATH);
return actionOk();
},
@@ -89,7 +98,10 @@ export const alertUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
await executeLegacyRcon(ctx, "rcon.alert-user", {
userId: ctx.data.userId,
message,
});
revalidatePath(PATH);
return actionOk();
},
@@ -104,9 +116,7 @@ const forwardUserSchema = z.object({
export const forwardUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
async (ctx) => {
await requireRconOk(
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
);
await executeLegacyRcon(ctx, "rcon.forward-user", ctx.data);
revalidatePath(PATH);
return actionOk();
},
@@ -121,9 +131,10 @@ const giveCreditsSchema = z.object({
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
await executeLegacyRcon(ctx, "rcon.give-credits", {
userId: ctx.data.userId,
amount: ctx.data.credits,
});
revalidatePath(PATH);
return actionOk();
},
@@ -138,9 +149,7 @@ const giveAmountSchema = z.object({
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
await executeLegacyRcon(ctx, "rcon.give-duckets", ctx.data);
revalidatePath(PATH);
return actionOk();
},
@@ -150,9 +159,7 @@ export const giveDuckets = adminAction(
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
await executeLegacyRcon(ctx, "rcon.give-diamonds", ctx.data);
revalidatePath(PATH);
return actionOk();
},
@@ -168,7 +175,10 @@ export const giveBadge = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
async (ctx) => {
const badge = ctx.data.badge.normalize("NFC");
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
await executeLegacyRcon(ctx, "rcon.give-badge", {
userId: ctx.data.userId,
badge,
});
revalidatePath(PATH);
return actionOk();
},
@@ -184,7 +194,10 @@ export const setMotto = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
async (ctx) => {
const motto = ctx.data.motto.normalize("NFC");
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
await executeLegacyRcon(ctx, "rcon.set-motto", {
userId: ctx.data.userId,
motto,
});
revalidatePath(PATH);
return actionOk();
},
@@ -199,28 +212,9 @@ const setRankSchema = z.object({
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
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);
const result = await executeLegacyRcon(ctx, "rcon.set-rank", ctx.data);
revalidatePath(PATH);
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>);
return actionOk(result as Record<string, unknown>);
},
);
@@ -234,7 +228,10 @@ export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
const command = ctx.data.command.normalize("NFC");
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
await executeLegacyRcon(ctx, "rcon.execute-command", {
userId: ctx.data.userId,
command,
});
revalidatePath(PATH);
return actionOk();
},
@@ -251,9 +248,11 @@ export const sendGift = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
async (ctx) => {
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
await requireRconOk(
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
);
await executeLegacyRcon(ctx, "rcon.send-gift", {
userId: ctx.data.userId,
itemId: ctx.data.itemId,
message,
});
revalidatePath(PATH);
return actionOk();
},
+9 -16
View File
@@ -1,22 +1,13 @@
// @ts-nocheck
import { describe, expect, it, vi } from "vitest";
import { rcon } from "@/lib/services/rcon";
const { insertValues } = vi.hoisted(() => {
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
}));
return { insertValues };
});
const { executeSystem } = vi.hoisted(() => ({ executeSystem: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
},
EmulatorSettings: { key: "key", value: "value" },
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
executeLegacySystemMutation: executeSystem,
}));
vi.mock("@/lib/safe-action", () => ({
adminAction: vi.fn(
@@ -24,11 +15,10 @@ vi.mock("@/lib/safe-action", () => ({
),
}));
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
describe("saveEmulatorSettings", () => {
it("saves settings and calls rcon update", async () => {
executeSystem.mockResolvedValue({ saved: 2 });
const handler = (await import("./emulator").then(
(m) => m.saveEmulatorSettings,
)) as unknown as (ctx: {
@@ -41,8 +31,11 @@ describe("saveEmulatorSettings", () => {
session: { user: { id: "1" } },
});
expect(insertValues).toHaveBeenCalledTimes(2);
expect(rcon.updateConfig).toHaveBeenCalled();
expect(executeSystem).toHaveBeenCalledWith(
{ id: 1 },
"configuration.emulator-settings.save",
{ settings: { key1: "val1", key2: "val2" } },
);
expect(result).toBe("ok");
});
});
+6 -20
View File
@@ -1,12 +1,10 @@
"use server";
import { z } from "zod";
import { db, EmulatorSettings } from "@/lib/db";
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
@@ -15,23 +13,11 @@ const saveEmulatorSettingsSchema = z.object({
export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => {
const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) {
await db
.insert(EmulatorSettings)
.values({ key, value: String(value) })
.onDuplicateKeyUpdate({ set: { value: String(value) } });
}
await rcon.updateConfig();
logAudit({
userId: ctx.session.user.id,
action: "emulator_settings_update",
target: "EmulatorSettings",
after: ctx.data.settings,
});
await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"configuration.emulator-settings.save",
ctx.data,
);
return actionOk();
},
+25 -186
View File
@@ -1,22 +1,10 @@
"use server";
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 { 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 { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
import { PERMS } from "@/lib/permission-slugs";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import {
createEmulatorRank,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { actionOk } from "@/lib/safe-action-shared";
const createRankSchema = z.object({
rank_name: z.string().trim().min(1).max(25),
@@ -26,25 +14,12 @@ const createRankSchema = z.object({
export const createRank = adminAction(
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const id = await createEmulatorRank(db, ctx.data);
await db
.insert(AclRole)
.values({
slug: `rank_${id}`,
title: ctx.data.rank_name,
description: "CMS role synchronized from permission_ranks",
})
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_create",
description: `Created rank #${id}`,
targetType: "rank",
targetId: id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk({ id });
const result = (await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"access.rank.create",
{ name: ctx.data.rank_name, level: ctx.data.level },
)) as { id: number };
return actionOk({ id: result.id });
},
);
@@ -53,27 +28,11 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
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(),
},
await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"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();
},
);
@@ -86,22 +45,11 @@ const saveRankSchema = z.object({
export const saveRank = adminAction(
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
if (typeof ctx.data.fields.rank_name === "string") {
await db
.update(AclRole)
.set({ title: ctx.data.fields.rank_name })
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
}
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_update",
description: `Updated rank #${ctx.data.id}`,
targetType: "rank",
targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"access.rank.update",
ctx.data,
);
return actionOk();
},
);
@@ -114,43 +62,11 @@ const setCmsPermsSchema = z.object({
export const setCmsPermissions = adminAction(
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const [role] = await db
.select({ id: AclRole.id, slug: AclRole.slug })
.from(AclRole)
.where(eq(AclRole.id, ctx.data.roleId))
.limit(1);
if (!role) throw new ActionError("Role not found");
const permissions = await db
.select({ id: AclPermission.id })
.from(AclPermission)
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
await db.transaction(async (tx) => {
await tx
.delete(AclModelPermission)
.where(
and(
eq(AclModelPermission.modelId, role.id),
eq(AclModelPermission.modelType, "Role"),
),
await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"access.permissions.update",
ctx.data,
);
if (permissions.length) {
await tx.insert(AclModelPermission).values(
permissions.map((permission) => ({
modelId: role.id,
modelType: "Role",
permissionId: permission.id,
})),
);
}
});
await logStaffActivity({
staffId: ctx.session.user.id,
action: "acl_role_permissions_update",
description: `Updated ${permissions.length} permissions for ${role.slug}`,
targetType: "acl_role",
targetId: role.id,
});
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
);
@@ -164,88 +80,11 @@ export const setCmsPermissions = adminAction(
export const repairAdminNavAclGrants = adminAction(
{ permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const [dashboardFillResult] = await db.execute(sql`
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`acl_roles\` ar
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
WHERE EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND apdash.slug = 'admin.dashboard'
)
AND NOT EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp2
WHERE amp2.model_type = 'Role'
AND amp2.model_id = ar.id
AND amp2.permission_id = ap.id
)
`);
const [midRankViewsResult] = await db.execute(sql`
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`permission_ranks\` pr
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN \`acl_permissions\` ap ON (
ap.slug = 'admin.dashboard'
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
)
WHERE pr.id >= 6
AND NOT EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
)
`);
const [highRankToolsResult] = await db.execute(sql`
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`permission_ranks\` pr
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN \`acl_permissions\` ap ON (
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
OR ap.slug IN (
'admin.permissions.manage',
'admin.rcon.execute',
'admin.assets.import',
'admin.export',
'admin.analytics.export',
'admin.users.ban',
'admin.users.reset_password',
'admin.room.delete'
)
)
WHERE pr.id >= 7
AND NOT EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
)
`);
const inserted =
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
Number((highRankToolsResult as ResultSetHeader).affectedRows);
await logStaffActivity({
staffId: ctx.session.user.id,
action: "acl_nav_grants_repair",
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
targetType: "acl",
targetId: 0,
});
revalidateTag("permissions", { expire: 0 });
return actionOk({ inserted });
const result = (await executeLegacySystemMutation(
{ id: Number(ctx.session.user.id) },
"access.permissions.repair",
{},
)) as { inserted: number };
return actionOk({ inserted: result.inserted });
},
);
@@ -0,0 +1,94 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const doubles = vi.hoisted(() => ({
execute: vi.fn(),
requirePermission: vi.fn(),
requirePermissionRateLimited: vi.fn(),
revalidatePath: vi.fn(),
}));
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
executeLegacySystemMutation: doubles.execute,
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: doubles.requirePermission,
requirePermissionRateLimited: doubles.requirePermissionRateLimited,
}));
vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs"));
vi.mock("next/cache", () => ({
revalidatePath: doubles.revalidatePath,
revalidateTag: vi.fn(),
}));
vi.mock("@/lib/foundation/action", () => ({
actionOk: (data = {}) => ({ ok: true, data }),
adminAction: (_options, handler) => handler,
}));
vi.mock("@/lib/safe-action", () => ({
adminAction: (_options, handler) => handler,
}));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data = {}) => ({ ok: true, data }),
}));
import { updateEmulatorSetting } from "./admin-emulator";
import { createSetting } from "./admin-settings";
import { updateCatalog } from "./commandocentrum";
import { setCmsPermissions } from "./permissions";
const actor = { id: 42, username: "operator", rank: 9 };
beforeEach(() => {
vi.clearAllMocks();
doubles.execute.mockResolvedValue(null);
doubles.requirePermission.mockResolvedValue(actor);
doubles.requirePermissionRateLimited.mockResolvedValue(actor);
});
describe("legacy System adapters", () => {
it("routes the create-setting FormData contract through the canonical upsert", async () => {
const form = new FormData();
form.set("key", " hotel_name ");
form.set("value", "Epic Hotel");
form.set("comment", "Display name");
await createSetting(form);
expect(doubles.execute).toHaveBeenCalledWith(
actor,
"configuration.setting.create",
{ key: "hotel_name", value: "Epic Hotel", comment: "Display name" },
);
});
it("routes emulator FormData through the audited single-key operation", async () => {
const form = new FormData();
form.set("key", " hotel.name ");
form.set("value", "Epic");
await updateEmulatorSetting(form);
expect(doubles.execute).toHaveBeenCalledWith(
actor,
"configuration.emulator-setting.update",
{ key: "hotel.name", value: "Epic" },
);
});
it("keeps explicit empty permission lists as canonical revoke-all", async () => {
await setCmsPermissions({
data: { roleId: 5, permissionSlugs: [] },
session: { user: { id: 42 } },
});
expect(doubles.execute).toHaveBeenCalledWith(
{ id: 42 },
"access.permissions.update",
{ roleId: 5, permissionSlugs: [] },
);
});
it("routes command-center RCON through the external audit boundary", async () => {
await updateCatalog({ session: { user: { id: 42 } } });
expect(doubles.execute).toHaveBeenCalledWith(
{ id: 42 },
"rcon.update-catalog",
{},
);
});
});
@@ -52,6 +52,8 @@ const reasonRequiredIds = expectedCommandIds.filter(
id.startsWith("system.access.") ||
id.startsWith("system.configuration.setting") ||
id === "system.configuration.settings.save" ||
id === "system.configuration.emulator-setting.update" ||
id === "system.configuration.emulator-text.update" ||
id === "system.operations.alert.broadcast" ||
id.startsWith("system.operations.rcon.") ||
id === "system.operations.maintenance.update",
@@ -172,14 +172,14 @@ export function createSystemCommands(
operation: "configuration.emulator-setting.update",
capability: PERMS.SETTINGS_EDIT,
input: z.object({ key: requiredText(100), value: z.string().max(512) }),
requiresReason: false,
requiresReason: true,
}),
systemCommand(service, {
id: "system.configuration.emulator-text.update",
operation: "configuration.emulator-text.update",
capability: PERMS.SETTINGS_EDIT,
input: z.object({ key: requiredText(100), value: z.string().max(4096) }),
requiresReason: false,
requiresReason: true,
}),
systemCommand(service, {
id: "system.operations.alerts.mark-read",
@@ -2,11 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { markReadWhere, rconSend } = vi.hoisted(() => ({
const { logAudit, markReadWhere, rconSend } = vi.hoisted(() => ({
logAudit: vi.fn(),
markReadWhere: vi.fn(),
rconSend: vi.fn(),
}));
vi.mock("@/lib/services/audit", () => ({ logAudit }));
vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return {
@@ -47,6 +50,8 @@ describe("System production mutation failures", () => {
beforeEach(() => {
markReadWhere.mockReset();
rconSend.mockReset();
logAudit.mockReset();
logAudit.mockResolvedValue(undefined);
});
it("maps a failed alert broadcast to dependency unavailable", async () => {
@@ -87,6 +92,37 @@ describe("System production mutation failures", () => {
});
});
it("returns a truthful partial when alert delivery succeeds but outcome audit fails", async () => {
rconSend.mockResolvedValue(true);
logAudit
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("audit unavailable"));
const result = await systemMutationService.execute(
{
capability: capabilityContext([PERMS.NOTIFICATIONS_EDIT]),
correlationId: "broadcast-audit-partial",
reason: "Approved hotel notice",
},
"operations.alert.broadcast",
{ message: "Hotel notice" },
);
expect(result).toMatchObject({
ok: true,
data: { delivered: true },
completion: {
status: "partial",
external: "completed",
audit: "unavailable",
},
});
expect(logAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
"intent",
"success",
]);
});
it("maps mark-read persistence failure to dependency unavailable", async () => {
markReadWhere.mockRejectedValue(new Error("database unavailable"));
@@ -2,6 +2,7 @@ import "server-only";
import { and, count, eq, inArray, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache";
import { MANAGED_SETTING_KEYS } from "@/lib/admin/cms-settings-config";
import {
AclModelPermission,
@@ -26,7 +27,9 @@ import { clearBadgeCache } from "@/lib/services/habboassets";
import {
createEmulatorRankInTransaction,
deleteEmulatorRankInTransaction,
fetchEmulatorRankForEdit,
prepareEmulatorRankCreation,
RANK_GENERAL_FIELDS,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import {
@@ -36,10 +39,7 @@ import {
} from "@/lib/services/rank-assignment";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import {
logStaffActivity,
logStaffActivityInTransaction,
} from "@/lib/services/staff-activity";
import { logStaffActivityInTransaction } from "@/lib/services/staff-activity";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
anyCapability,
@@ -62,6 +62,7 @@ export type SystemMutationOperation =
| "configuration.setting.update"
| "configuration.setting.delete"
| "configuration.emulator-setting.update"
| "configuration.emulator-settings.save"
| "configuration.emulator-text.update"
| "operations.alerts.mark-read"
| "operations.alert.broadcast"
@@ -87,6 +88,7 @@ export interface SystemMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly reason?: string;
readonly legacy?: boolean;
}
export interface SystemMutationAdapter {
@@ -129,6 +131,15 @@ class SystemCommittedExternalFailure extends Error {
}
}
class SystemCommittedCacheFailure extends Error {
constructor(readonly data: unknown) {
super(
"System database change committed but cache invalidation is incomplete",
);
this.name = "SystemCommittedCacheFailure";
}
}
function operationCapability(operation: SystemMutationOperation) {
if (operation.startsWith("access.")) {
return anyCapability(PERMS.PERMISSIONS_MANAGE);
@@ -185,6 +196,14 @@ export function createSystemMutationService(
audit: error.audit,
});
}
if (error instanceof SystemCommittedCacheFailure) {
return ok(error.data, context.correlationId, {
status: "partial",
external: "not-required",
audit: "persisted",
cache: "unavailable",
});
}
if (error instanceof SystemMutationFailure) {
return fail(
error.code,
@@ -515,13 +534,53 @@ async function upsertWebsiteSetting(
key: string,
value: string,
comment?: string | null,
writer: Pick<typeof db, "insert"> = db,
): Promise<void> {
await db
await writer
.insert(WebsiteSetting)
.values({ key, value, ...(comment === undefined ? {} : { comment }) })
.onDuplicateKeyUpdate({ set: { value } });
}
async function loadWebsiteSettingValues(
reader: Pick<typeof db, "select">,
keys: readonly string[],
): Promise<Record<string, string>> {
if (keys.length === 0) return {};
const rows = await reader
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting)
.where(inArray(WebsiteSetting.key, [...keys]));
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
function mutationAuditEntry(
operation: SystemMutationOperation,
context: SystemMutationContext,
before?: Record<string, unknown>,
after?: Record<string, unknown>,
) {
return {
userId: context.capability.actor.id,
action: `system.${operation}`,
target: operation,
correlationId: context.correlationId,
outcome: "success" as const,
reason: context.reason,
domain: "system" as const,
before,
after,
};
}
async function invalidatePermissionsCache(data: unknown): Promise<void> {
try {
revalidateTag("permissions", { expire: 0 });
} catch {
throw new SystemCommittedCacheFailure(data);
}
}
async function executeAccessMutation(
operation: Extract<SystemMutationOperation, `access.${string}`>,
input: unknown,
@@ -553,6 +612,16 @@ async function executeAccessMutation(
description: "CMS role synchronized from permission_ranks",
})
.onDuplicateKeyUpdate({ set: { title: name } });
const after = await fetchEmulatorRankForEdit(tx, id);
await logAudit(
mutationAuditEntry(
operation,
context,
undefined,
(after ?? { id, rank_name: name, level }) as Record<string, unknown>,
),
tx,
);
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
@@ -585,6 +654,7 @@ async function executeAccessMutation(
"errors.housekeeping.system.rankNotFound",
);
}
const before = await fetchEmulatorRankForEdit(tx, id);
const [userCount] = await tx
.select({ total: count() })
@@ -616,6 +686,15 @@ async function executeAccessMutation(
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
}
await logAudit(
mutationAuditEntry(
operation,
context,
before as unknown as Record<string, unknown>,
undefined,
),
tx,
);
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
@@ -636,6 +715,12 @@ async function executeAccessMutation(
if (operation === "access.rank.update") {
const fields = record(data.fields);
if (Object.keys(fields).length === 0) {
throw new SystemMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
const normalizedFields = Object.fromEntries(
Object.entries(fields).flatMap(([key, value]) =>
typeof value === "string" || typeof value === "number"
@@ -649,6 +734,32 @@ async function executeAccessMutation(
rankId: id,
} as const satisfies SystemExternalSyncIntent;
await db.transaction(async (tx) => {
if (!(await lockConfiguredRank(tx, id))) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.rankNotFound",
);
}
const before = await fetchEmulatorRankForEdit(tx, id);
if (!before) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.rankNotFound",
);
}
const knownFields = new Set([
...RANK_GENERAL_FIELDS,
...Object.keys(before.permissions),
]);
if (
Object.keys(normalizedFields).length === 0 ||
Object.keys(normalizedFields).some((key) => !knownFields.has(key))
) {
throw new SystemMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
await updateEmulatorRank(tx, id, normalizedFields);
if (typeof normalizedFields.rank_name === "string") {
await tx
@@ -666,6 +777,16 @@ async function executeAccessMutation(
},
tx,
);
const after = await fetchEmulatorRankForEdit(tx, id);
await logAudit(
mutationAuditEntry(
operation,
context,
before as unknown as Record<string, unknown>,
(after ?? before) as unknown as Record<string, unknown>,
),
tx,
);
await logAudit(
syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
@@ -677,13 +798,17 @@ async function executeAccessMutation(
if (operation === "access.permissions.update") {
const roleId = positiveInteger(data.roleId);
const slugs = Array.isArray(data.permissionSlugs)
? data.permissionSlugs.map((slug) => text(slug, 160)).filter(Boolean)
? [
...new Set(
data.permissionSlugs.map((slug) => text(slug, 160)).filter(Boolean),
),
]
: [];
const [role] = await db
.select({ id: AclRole.id, slug: AclRole.slug })
.from(AclRole)
.where(eq(AclRole.id, roleId))
.limit(1);
const updated = await db.transaction(async (tx) => {
const [lockedRows] = await tx.execute(sql`
SELECT id, slug FROM acl_roles WHERE id = ${roleId} FOR UPDATE
`);
const role = (lockedRows as unknown as { id: number; slug: string }[])[0];
if (!role) {
throw new SystemMutationFailure(
"NOT_FOUND",
@@ -691,12 +816,27 @@ async function executeAccessMutation(
);
}
const permissions = slugs.length
? await db
.select({ id: AclPermission.id })
? await tx
.select({ id: AclPermission.id, slug: AclPermission.slug })
.from(AclPermission)
.where(inArray(AclPermission.slug, slugs))
: [];
await db.transaction(async (tx) => {
if (permissions.length !== slugs.length) {
throw new SystemMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
{ permissionSlugs: ["errors.validation.invalid"] },
);
}
const beforeRows = await tx
.select({ permissionId: AclModelPermission.permissionId })
.from(AclModelPermission)
.where(
and(
eq(AclModelPermission.modelId, role.id),
eq(AclModelPermission.modelType, "Role"),
),
);
await tx
.delete(AclModelPermission)
.where(
@@ -714,18 +854,39 @@ async function executeAccessMutation(
})),
);
}
});
await logStaffActivity({
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
action: "acl_role_permissions_update",
description: `Updated ${permissions.length} permissions for ${role.slug}`,
targetType: "acl_role",
targetId: role.id,
});
},
tx,
);
await logAudit(
mutationAuditEntry(
operation,
context,
{
roleId: role.id,
permissionIds: beforeRows.map((row) => row.permissionId),
},
{
roleId: role.id,
permissionSlugs: permissions.map((permission) => permission.slug),
},
),
tx,
);
return { updated: permissions.length };
});
await invalidatePermissionsCache(updated);
return updated;
}
const [dashboardFillResult] = await db.execute(sql`
const inserted = await db.transaction(async (tx) => {
const [dashboardFillResult] = await tx.execute(sql`
INSERT INTO acl_model_permissions (model_type, model_id, permission_id)
SELECT 'Role', ar.id, ap.id
FROM acl_roles ar
@@ -742,7 +903,7 @@ async function executeAccessMutation(
AND amp2.permission_id = ap.id
)
`);
const [midRankViewsResult] = await db.execute(sql`
const [midRankViewsResult] = await tx.execute(sql`
INSERT INTO acl_model_permissions (model_type, model_id, permission_id)
SELECT 'Role', ar.id, ap.id
FROM permission_ranks pr
@@ -760,7 +921,7 @@ async function executeAccessMutation(
AND amp.permission_id = ap.id
)
`);
const [highRankToolsResult] = await db.execute(sql`
const [highRankToolsResult] = await tx.execute(sql`
INSERT INTO acl_model_permissions (model_type, model_id, permission_id)
SELECT 'Role', ar.id, ap.id
FROM permission_ranks pr
@@ -787,23 +948,35 @@ async function executeAccessMutation(
AND amp.permission_id = ap.id
)
`);
const inserted =
const total =
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
Number((highRankToolsResult as ResultSetHeader).affectedRows);
await logStaffActivity({
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
action: "acl_nav_grants_repair",
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
description: `Repaired admin nav ACL grants (${total} rows inserted)`,
targetType: "acl",
targetId: 0,
},
tx,
);
await logAudit(
mutationAuditEntry(operation, context, undefined, { inserted: total }),
tx,
);
return total;
});
return { inserted };
const repaired = { inserted };
await invalidatePermissionsCache(repaired);
return repaired;
}
async function executeConfigurationMutation(
operation: Extract<SystemMutationOperation, `configuration.${string}`>,
input: unknown,
context: SystemMutationContext,
): Promise<unknown> {
const data = record(input);
if (operation === "configuration.settings.save") {
@@ -814,13 +987,33 @@ async function executeConfigurationMutation(
? [[key, normalizeSettingValue(key, value)] as const]
: [],
);
await Promise.all(
entries.map(([key, value]) => upsertWebsiteSetting(key, value)),
await db.transaction(async (tx) => {
const before = await loadWebsiteSettingValues(
tx,
entries.map(([key]) => key),
);
await siteSettings.reload();
for (const [key, value] of entries)
await upsertWebsiteSetting(key, value, undefined, tx);
await logAudit(
mutationAuditEntry(
operation,
context,
{ settings: before },
{ settings: Object.fromEntries(entries) },
),
tx,
);
});
const invalidation = await siteSettings.reload();
if (!invalidation.invalidated)
throw new SystemCommittedCacheFailure({ saved: entries.length });
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
try {
clearOfficialHabboFurnidataCache();
clearBadgeCache();
} catch {
throw new SystemCommittedCacheFailure({ saved: entries.length });
}
}
return { saved: entries.length };
}
@@ -834,13 +1027,52 @@ async function executeConfigurationMutation(
);
}
const value = text(data.value, 512, false);
await db
await db.transaction(async (tx) => {
await tx
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await logAudit(
mutationAuditEntry(operation, context, undefined, { key, value }),
tx,
);
});
return null;
}
if (operation === "configuration.emulator-settings.save") {
const settings = record(data.settings);
const entries = Object.entries(settings)
.map(([key, value]) => [text(key, 100), text(value, 512, false)] as const)
.filter(([key]) => Boolean(key));
await db.transaction(async (tx) => {
for (const [key, value] of entries) {
await tx
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
}
await logAudit(
mutationAuditEntry(operation, context, undefined, {
settings: Object.fromEntries(entries),
}),
tx,
);
});
let synchronized = false;
try {
synchronized = await rcon.updateConfig();
} catch {}
if (!synchronized) {
throw new SystemCommittedExternalFailure(
{ saved: entries.length },
"failed",
"persisted",
);
}
return { saved: entries.length };
}
if (operation === "configuration.emulator-text.update") {
const key = text(data.key, 100);
if (!key) {
@@ -850,10 +1082,16 @@ async function executeConfigurationMutation(
);
}
const value = text(data.value, 4096, false);
await db
await db.transaction(async (tx) => {
await tx
.insert(EmulatorTexts)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await logAudit(
mutationAuditEntry(operation, context, undefined, { key, value }),
tx,
);
});
return null;
}
@@ -865,9 +1103,26 @@ async function executeConfigurationMutation(
);
}
if (operation === "configuration.setting.delete") {
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await siteSettings.reload();
await db.transaction(async (tx) => {
const before = await loadWebsiteSettingValues(tx, [key]);
await tx.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await logAudit(
mutationAuditEntry(
operation,
context,
{ key, value: before[key] },
undefined,
),
tx,
);
});
const invalidation = await siteSettings.reload();
if (!invalidation.invalidated) throw new SystemCommittedCacheFailure(null);
try {
bustGamedataCachesIfNeeded(key);
} catch {
throw new SystemCommittedCacheFailure(null);
}
return null;
}
@@ -876,9 +1131,30 @@ async function executeConfigurationMutation(
operation === "configuration.setting.create"
? text(data.comment, 255) || null
: undefined;
await upsertWebsiteSetting(key, value, comment);
await siteSettings.reload();
await db.transaction(async (tx) => {
const before = await loadWebsiteSettingValues(tx, [key]);
await upsertWebsiteSetting(key, value, comment, tx);
await logAudit(
mutationAuditEntry(
operation,
context,
{ key, value: before[key] },
{
key,
value,
...(comment === undefined ? {} : { comment }),
},
),
tx,
);
});
const invalidation = await siteSettings.reload();
if (!invalidation.invalidated) throw new SystemCommittedCacheFailure(null);
try {
bustGamedataCachesIfNeeded(key);
} catch {
throw new SystemCommittedCacheFailure(null);
}
return null;
}
@@ -888,6 +1164,16 @@ async function executeRconMutation(
context: SystemMutationContext,
): Promise<unknown> {
const data = record(input);
const usesDurableDatabaseIntent =
operation === "rcon.set-rank" || operation === "rcon.set-rank.retry";
if (!usesDurableDatabaseIntent) {
await logAudit({
...mutationAuditEntry(operation, context),
outcome: "intent",
});
}
let externalCompleted = false;
try {
switch (operation) {
case "rcon.update-catalog":
await requireRcon(await rcon.updateCatalog());
@@ -1036,7 +1322,40 @@ async function executeRconMutation(
);
break;
}
externalCompleted = true;
if (!usesDurableDatabaseIntent) {
try {
await logAudit(
mutationAuditEntry(operation, context, undefined, {
delivered: true,
}),
);
} catch {
throw new SystemCommittedExternalFailure(
null,
"completed",
"unavailable",
);
}
}
return null;
} catch (error) {
if (
!usesDurableDatabaseIntent &&
!externalCompleted &&
!(error instanceof SystemCommittedExternalFailure)
) {
try {
await logAudit({
...mutationAuditEntry(operation, context),
outcome: "failure",
});
} catch {
// Preserve the authoritative external failure.
}
}
throw error;
}
}
const systemProductionMutationAdapter: SystemMutationAdapter = {
@@ -1055,6 +1374,7 @@ const systemProductionMutationAdapter: SystemMutationAdapter = {
`configuration.${string}`
>,
input,
context,
);
}
if (operation.startsWith("rcon.")) {
@@ -1066,10 +1386,21 @@ const systemProductionMutationAdapter: SystemMutationAdapter = {
}
if (operation === "operations.alerts.mark-read") {
await db
await db.transaction(async (tx) => {
await tx
.update(AlertLogs)
.set({ isRead: true, updatedAt: new Date() })
.where(eq(AlertLogs.isRead, false));
await logAudit(
mutationAuditEntry(
operation,
context,
{ unread: true },
{ unread: false },
),
tx,
);
});
return null;
}
if (operation === "operations.alert.broadcast") {
@@ -1080,8 +1411,39 @@ const systemProductionMutationAdapter: SystemMutationAdapter = {
"errors.housekeeping.validation",
);
}
await logAudit({
...mutationAuditEntry(operation, context),
outcome: "intent",
});
let delivered = false;
try {
await requireRcon(await rcon.send("hotelalert", { message }));
delivered = true;
try {
await logAudit(
mutationAuditEntry(operation, context, undefined, {
delivered: true,
}),
);
} catch {
throw new SystemCommittedExternalFailure(
{ delivered: true },
"completed",
"unavailable",
);
}
return { delivered: true };
} catch (error) {
if (!delivered && !(error instanceof SystemCommittedExternalFailure)) {
try {
await logAudit({
...mutationAuditEntry(operation, context),
outcome: "failure",
});
} catch {}
}
throw error;
}
}
const data = record(input);
@@ -1107,10 +1469,30 @@ const systemProductionMutationAdapter: SystemMutationAdapter = {
"The minimum rank required to login to the hotel during maintenance",
],
] as const;
await db.transaction(async (tx) => {
const before = await loadWebsiteSettingValues(
tx,
rows.map(([key]) => key),
);
for (const [key, value, comment] of rows) {
await upsertWebsiteSetting(key, value, comment);
await upsertWebsiteSetting(key, value, comment, tx);
}
await siteSettings.reload();
await logAudit(
mutationAuditEntry(
operation,
context,
{ settings: before },
{
enabled,
message,
minimumLoginRank,
},
),
tx,
);
});
const invalidation = await siteSettings.reload();
if (!invalidation.invalidated) throw new SystemCommittedCacheFailure(null);
return null;
},
};
@@ -1118,3 +1500,40 @@ const systemProductionMutationAdapter: SystemMutationAdapter = {
export const systemMutationService = createSystemMutationService(
systemProductionMutationAdapter,
);
export async function executeLegacySystemMutation(
actor: { readonly id: number },
operation: SystemMutationOperation,
input: unknown,
): Promise<unknown> {
const [{ getHousekeepingCapabilityContext }, { createCorrelationId }] =
await Promise.all([
import("../../../foundation/server-capability-context"),
import("../../../foundation/correlation"),
]);
const capability = await getHousekeepingCapabilityContext();
const correlationId = createCorrelationId();
if (capability.actor.id !== actor.id) {
const error = new Error("errors.housekeeping.forbidden");
error.name = "ActionError";
throw error;
}
const result = await systemMutationService.execute(
{ capability, correlationId, legacy: true },
operation,
input,
);
if (!result.ok) {
const error = new Error(result.error.messageKey);
error.name = "ActionError";
throw error;
}
if (result.completion) {
const error = new Error(
`System change committed; synchronization or cache invalidation is incomplete. Reference: ${result.correlationId}`,
);
error.name = "ActionError";
throw error;
}
return result.data;
}
@@ -19,6 +19,7 @@ const doubles = vi.hoisted(() => ({
dbUpdate: vi.fn(),
deleteEmulatorRank: vi.fn(),
deleteEmulatorRankInTransaction: vi.fn(),
fetchEmulatorRankForEdit: vi.fn(),
logAudit: vi.fn(),
logStaffActivity: vi.fn(),
logStaffActivityInTransaction: vi.fn(),
@@ -57,10 +58,19 @@ vi.mock("@/lib/services/audit", () => ({
}));
vi.mock("@/lib/services/permission-ranks", () => ({
RANK_GENERAL_FIELDS: new Set([
"rank_name",
"badge",
"level",
"prefix",
"prefix_color",
"hidden_rank",
]),
createEmulatorRank: doubles.createEmulatorRank,
createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction,
deleteEmulatorRank: doubles.deleteEmulatorRank,
deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction,
fetchEmulatorRankForEdit: doubles.fetchEmulatorRankForEdit,
prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation,
updateEmulatorRank: doubles.updateEmulatorRank,
}));
@@ -142,11 +152,29 @@ beforeEach(() => {
doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined);
doubles.deleteEmulatorRank.mockResolvedValue(undefined);
doubles.deleteEmulatorRankInTransaction.mockResolvedValue(undefined);
doubles.fetchEmulatorRankForEdit.mockResolvedValue({
id: 7,
rank_name: "Moderator",
badge: "MOD",
level: 6,
prefix: "",
prefix_color: "",
hidden_rank: "0",
log_commands: "1",
room_effect: 0,
auto_credits_amount: 0,
auto_pixels_amount: 0,
auto_gotw_amount: 0,
auto_points_amount: 0,
permissions: { cmd_alert: "1" },
permissionMaxValues: { cmd_alert: 1 },
});
doubles.updateEmulatorRank.mockResolvedValue(undefined);
doubles.logAudit.mockResolvedValue(undefined);
doubles.logStaffActivity.mockResolvedValue(undefined);
doubles.logStaffActivityInTransaction.mockResolvedValue(undefined);
doubles.dbDelete.mockImplementation(deleteChain);
doubles.dbExecute.mockResolvedValue([[{ id: 7 }]]);
doubles.dbInsert.mockImplementation(insertChain);
doubles.dbTransaction.mockImplementation(async (run) =>
run(transactionToken),
@@ -186,6 +214,45 @@ function expectPendingSynchronization(
}
describe("durable rank synchronization", () => {
it("rejects a missing rank update before any write or external synchronization", async () => {
doubles.dbExecute.mockResolvedValueOnce([[]]);
doubles.fetchEmulatorRankForEdit.mockResolvedValueOnce(null);
const result = await systemMutationService.execute(
serviceContext(PERMS.PERMISSIONS_MANAGE),
"access.rank.update",
{ id: 7, fields: { badge: "ADM" } },
);
expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
expect(doubles.updateEmulatorRank).not.toHaveBeenCalled();
expect(doubles.rconSend).not.toHaveBeenCalled();
});
it("rejects empty rank update fields without opening a transaction", async () => {
const result = await systemMutationService.execute(
serviceContext(PERMS.PERMISSIONS_MANAGE),
"access.rank.update",
{ id: 7, fields: {} },
);
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(doubles.dbTransaction).not.toHaveBeenCalled();
expect(doubles.updateEmulatorRank).not.toHaveBeenCalled();
});
it("rejects an unknown rank field after locking the real row and before writing", async () => {
const result = await systemMutationService.execute(
serviceContext(PERMS.PERMISSIONS_MANAGE),
"access.rank.update",
{ id: 7, fields: { definitely_not_a_rank_field: "1" } },
);
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(doubles.dbTransaction).toHaveBeenCalledOnce();
expect(doubles.updateEmulatorRank).not.toHaveBeenCalled();
});
it.each([
["access.rank.delete", PERMS.PERMISSIONS_MANAGE, { id: 7 }],
["rcon.set-rank", PERMS.RCON_EXECUTE, { userId: 8, rank: 4 }],
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const doubles = vi.hoisted(() => ({
deleteWhere: vi.fn(),
insertValues: vi.fn(),
logAudit: vi.fn(),
reload: vi.fn(),
selectRows: [] as unknown[][],
transaction: vi.fn(),
txExecute: vi.fn(),
}));
function selectChain() {
const rows = doubles.selectRows.shift() ?? [];
return { from: () => ({ where: () => Promise.resolve(rows) }) };
}
const tx = {
delete: () => ({ where: doubles.deleteWhere }),
execute: doubles.txExecute,
insert: () => ({ values: doubles.insertValues }),
select: () => selectChain(),
update: vi.fn(),
};
vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return { ...actual, db: { ...actual.db, transaction: doubles.transaction } };
});
vi.mock("@/lib/services/audit", () => ({ logAudit: doubles.logAudit }));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: doubles.reload },
}));
import { systemMutationService } from "./mutations";
function capability(permission: string): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 9 },
isSuperAdmin: false,
has: (slug) => slug === permission,
hasAny: (...slugs) => slugs.includes(permission),
hasAll: (...slugs) => slugs.every((slug) => slug === permission),
};
}
function context(permission: string) {
return {
capability: capability(permission),
correlationId: "system-atomic",
reason: "Approved system change",
};
}
beforeEach(() => {
vi.clearAllMocks();
doubles.selectRows.length = 0;
doubles.transaction.mockImplementation(async (run) => run(tx));
doubles.txExecute.mockResolvedValue([[{ id: 5, slug: "rank_5" }]]);
doubles.insertValues.mockReturnValue({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
});
doubles.reload.mockResolvedValue({ invalidated: true, redis: "invalidated" });
doubles.logAudit.mockResolvedValue(undefined);
});
describe("System atomic database mutations", () => {
it("rejects an unresolved permission slug before replacing any grants", async () => {
doubles.selectRows.push([{ id: 11, slug: "admin.dashboard" }], []);
const result = await systemMutationService.execute(
context(PERMS.PERMISSIONS_MANAGE),
"access.permissions.update",
{ roleId: 5, permissionSlugs: [" admin.dashboard ", "missing.slug"] },
);
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(doubles.deleteWhere).not.toHaveBeenCalled();
expect(doubles.logAudit).not.toHaveBeenCalled();
});
it("rolls back a managed-settings batch when the second write fails", async () => {
let writes = 0;
doubles.insertValues.mockImplementation(() => ({
onDuplicateKeyUpdate: async () => {
writes += 1;
if (writes === 2) throw new Error("second write failed");
},
}));
const result = await systemMutationService.execute(
context(PERMS.SETTINGS_EDIT),
"configuration.settings.save",
{ settings: { cms_logo: "/logo.png", cms_favicon: "/favicon.ico" } },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(doubles.transaction).toHaveBeenCalledOnce();
expect(doubles.reload).not.toHaveBeenCalled();
});
it("rolls back a settings write when its canonical audit fails", async () => {
doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable"));
const result = await systemMutationService.execute(
context(PERMS.SETTINGS_EDIT),
"configuration.settings.save",
{ settings: { cms_logo: "/logo.png" } },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(doubles.reload).not.toHaveBeenCalled();
});
it("reports a committed settings write as partial when cache invalidation fails", async () => {
doubles.reload.mockResolvedValueOnce({
invalidated: false,
redis: "unavailable",
});
const result = await systemMutationService.execute(
context(PERMS.SETTINGS_EDIT),
"configuration.settings.save",
{ settings: { cms_logo: "/logo.png" } },
);
expect(result).toMatchObject({
ok: true,
data: { saved: 1 },
completion: { status: "partial", cache: "unavailable" },
});
});
});
@@ -83,6 +83,36 @@ function baseCommand<I, O>(
}
describe("dispatchHousekeepingCommand", () => {
it("preserves a committed cache-invalidation partial from the command", async () => {
register(
baseCommand("system.dispatch.cache-partial", {
input: z.object({}),
execute: async (context) =>
ok(null, context.correlationId, {
status: "partial",
external: "not-required",
audit: "persisted",
cache: "unavailable",
}),
}),
);
const result = await dispatchHousekeepingCommand(
{ commandId: "system.dispatch.cache-partial", input: {} },
dependencies(),
);
expect(result).toMatchObject({
ok: true,
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
cache: "unavailable",
},
});
});
it("returns a typed not-found result for an unknown command", async () => {
const result = await dispatchHousekeepingCommand(
{ commandId: "system.missing", input: {} },
@@ -64,6 +64,7 @@ const housekeepingResultSchema = z.discriminatedUnion("ok", [
status: z.literal("partial"),
external: z.enum(["not-required", "completed", "failed"]),
audit: z.enum(["persisted", "unavailable"]),
cache: z.enum(["invalidated", "unavailable"]).optional(),
})
.optional(),
}),
+11 -6
View File
@@ -3,12 +3,17 @@ import { describe, expect, it } from "vitest";
describe("ACL management contract", () => {
it("uses normalized ACL persistence and the permissions.manage guard", () => {
const source = readFileSync("src/actions/permissions.ts", "utf8");
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
expect(source).toContain("adminAction");
expect(source).toContain("AclModelPermission");
expect(source).not.toContain("websiteHousekeepingPermissions");
expect(source).not.toContain("websiteTeams");
const actionSource = readFileSync("src/actions/permissions.ts", "utf8");
const serviceSource = readFileSync(
"src/features/housekeeping/domains/system/services/mutations.ts",
"utf8",
);
expect(actionSource).toContain("PERMS.PERMISSIONS_MANAGE");
expect(actionSource).toContain("adminAction");
expect(actionSource).toContain("executeLegacySystemMutation");
expect(serviceSource).toContain("AclModelPermission");
expect(actionSource).not.toContain("websiteHousekeepingPermissions");
expect(actionSource).not.toContain("websiteTeams");
});
it("ships an idempotent ACL completion migration", () => {
+13 -17
View File
@@ -21,7 +21,7 @@ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
peopleMutationService: { execute: doubles.people },
}));
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
systemMutationService: { execute: doubles.system },
executeLegacySystemMutation: doubles.system,
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
@@ -54,9 +54,7 @@ beforeEach(() => {
correlationId: "operation-42",
});
doubles.system.mockResolvedValue({
ok: true,
data: { rank: 4 },
correlationId: "operation-42",
rank: 4,
});
});
@@ -64,30 +62,28 @@ 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 }),
}),
{ id: 42 },
"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 },
);
expect(doubles.system).toHaveBeenCalledWith({ id: 42 }, "rcon.set-rank", {
userId: 8,
rank: 4,
});
});
it("does not present a partially synchronized command-centre assignment as complete", async () => {
doubles.system.mockResolvedValue(partial);
doubles.system.mockRejectedValue(
new Error(
"System change committed; synchronization or cache invalidation is incomplete. Reference: recovery-42",
),
);
await expect(
setRank({ ...context, data: { userId: 8, rank: 4 } } as never),
).rejects.toThrow("Rank saved");
).rejects.toThrow("System change committed");
});
it("routes the old user editor through People, preserving fields and actor identity", async () => {