feat(housekeeping): make system mutations atomic
This commit is contained in:
1 parent
874cb2beb2
commit
1360b0ed59
21 files changed
+1223
-735
No files matched your search
@@ -2,9 +2,13 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
import { sendHotelAlert } from "./admin-alerts";
|
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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
||||||
@@ -15,7 +19,6 @@ vi.mock("@/lib/db", () => ({
|
|||||||
},
|
},
|
||||||
AlertLogs: {},
|
AlertLogs: {},
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
@@ -24,6 +27,7 @@ const fakeForm = (data: Record<string, string>) => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
executeSystem.mockResolvedValue({ delivered: true });
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
id: 1,
|
id: 1,
|
||||||
rank: 7,
|
rank: 7,
|
||||||
@@ -36,12 +40,16 @@ describe("sendHotelAlert", () => {
|
|||||||
await sendHotelAlert(
|
await sendHotelAlert(
|
||||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
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");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when message is empty", async () => {
|
it("returns early when message is empty", async () => {
|
||||||
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
||||||
expect(rcon.send).not.toHaveBeenCalled();
|
expect(executeSystem).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { AlertLogs, db } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast a hotel-wide alert to every online user via 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.
|
* `message` payload. Staff-gated; the message is trimmed/bounded before send.
|
||||||
*/
|
*/
|
||||||
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
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") ?? "")
|
const message = String(formData.get("message") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -22,26 +20,16 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
|||||||
.slice(0, 1000);
|
.slice(0, 1000);
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
try {
|
await executeLegacySystemMutation(actor, "operations.alert.broadcast", {
|
||||||
await rcon.send("hotelalert", { message });
|
message,
|
||||||
} catch {
|
});
|
||||||
// Best-effort delivery (dead socket / emulator offline) — never 500 the
|
|
||||||
// admin page. The emulator writes its own alert_logs row on receipt.
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/admin/alerts");
|
revalidatePath("/admin/alerts");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark every unread ops alert as read. */
|
/** Mark every unread ops alert as read. */
|
||||||
export async function markAllAlertsRead(): Promise<void> {
|
export async function markAllAlertsRead(): Promise<void> {
|
||||||
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
const actor = await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
||||||
try {
|
await executeLegacySystemMutation(actor, "operations.alerts.mark-read", {});
|
||||||
await db
|
|
||||||
.update(AlertLogs)
|
|
||||||
.set({ isRead: true, updatedAt: new Date() })
|
|
||||||
.where(eq(AlertLogs.isRead, false));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/alerts");
|
revalidatePath("/admin/alerts");
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
|
// 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.
|
// keys via upsert. We never migrate or drop them.
|
||||||
|
|
||||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
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") ?? "")
|
const key = String(formData.get("key") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -20,15 +20,16 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.slice(0, 512);
|
.slice(0, 512);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await db
|
await executeLegacySystemMutation(
|
||||||
.insert(EmulatorSettings)
|
actor,
|
||||||
.values({ key, value })
|
"configuration.emulator-setting.update",
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
{ key, value },
|
||||||
|
);
|
||||||
revalidatePath("/admin/emulator");
|
revalidatePath("/admin/emulator");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
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") ?? "")
|
const key = String(formData.get("key") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -37,9 +38,10 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.slice(0, 4096);
|
.slice(0, 4096);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await db
|
await executeLegacySystemMutation(
|
||||||
.insert(EmulatorTexts)
|
actor,
|
||||||
.values({ key, value })
|
"configuration.emulator-text.update",
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
{ key, value },
|
||||||
|
);
|
||||||
revalidatePath("/admin/emulator");
|
revalidatePath("/admin/emulator");
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const {
|
const { mockExecuteSystem, mockRequirePermission, mockRevalidatePath } =
|
||||||
mockValues,
|
vi.hoisted(() => {
|
||||||
mockOnDuplicateKeyUpdate,
|
return {
|
||||||
mockRequirePermission,
|
mockExecuteSystem: vi.fn(),
|
||||||
mockReload,
|
mockRequirePermission: vi.fn(),
|
||||||
mockRevalidatePath,
|
mockRevalidatePath: vi.fn(),
|
||||||
} = vi.hoisted(() => {
|
};
|
||||||
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
});
|
||||||
const mockValues = vi.fn(() => ({
|
|
||||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
mockValues,
|
|
||||||
mockOnDuplicateKeyUpdate,
|
|
||||||
mockRequirePermission: vi.fn(),
|
|
||||||
mockReload: vi.fn(),
|
|
||||||
mockRevalidatePath: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: {
|
PERMS: {
|
||||||
@@ -28,21 +17,14 @@ vi.mock("@/lib/permissions", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/db", () => ({
|
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
|
||||||
db: {
|
executeLegacySystemMutation: mockExecuteSystem,
|
||||||
insert: vi.fn(() => ({ values: mockValues })),
|
|
||||||
},
|
|
||||||
WebsiteSetting: { key: "key", value: "value" },
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
requirePermission: mockRequirePermission,
|
requirePermission: mockRequirePermission,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/site-settings", () => ({
|
|
||||||
siteSettings: { reload: mockReload },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next/cache", () => ({
|
vi.mock("next/cache", () => ({
|
||||||
revalidatePath: mockRevalidatePath,
|
revalidatePath: mockRevalidatePath,
|
||||||
}));
|
}));
|
||||||
@@ -51,10 +33,7 @@ import { saveMaintenance } from "./admin-maintenance";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockValues.mockReturnValue({
|
mockExecuteSystem.mockResolvedValue(null);
|
||||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
|
||||||
});
|
|
||||||
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveMaintenance", () => {
|
describe("saveMaintenance", () => {
|
||||||
@@ -74,28 +53,12 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
expect(mockRequirePermission).toHaveBeenCalled();
|
expect(mockRequirePermission).toHaveBeenCalled();
|
||||||
|
|
||||||
expect(mockValues).toHaveBeenCalledTimes(3);
|
expect(mockExecuteSystem).toHaveBeenCalledWith(
|
||||||
expect(mockValues).toHaveBeenCalledWith(
|
{ id: 1, rank: 7, username: "admin" },
|
||||||
expect.objectContaining({
|
"operations.maintenance.update",
|
||||||
key: "maintenance_enabled",
|
{ enabled: true, message: "We will be back soon!", minimumLoginRank: 3 },
|
||||||
value: "1",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
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");
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,17 +75,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockValues).toHaveBeenCalledWith(
|
expect(mockExecuteSystem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.anything(),
|
||||||
key: "maintenance_enabled",
|
"operations.maintenance.update",
|
||||||
value: "0",
|
expect.objectContaining({ enabled: false, minimumLoginRank: 5 }),
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mockValues).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: "min_maintenance_login_rank",
|
|
||||||
value: "5",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,11 +96,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockValues).toHaveBeenCalledWith(
|
expect(mockExecuteSystem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.anything(),
|
||||||
key: "min_maintenance_login_rank",
|
"operations.maintenance.update",
|
||||||
value: "5",
|
expect.objectContaining({ minimumLoginRank: 5 }),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,11 +117,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockValues).toHaveBeenCalledWith(
|
expect(mockExecuteSystem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.anything(),
|
||||||
key: "min_maintenance_login_rank",
|
"operations.maintenance.update",
|
||||||
value: "5",
|
expect.objectContaining({ minimumLoginRank: 5 }),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
|
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
|
||||||
// AtomCMS's MaintenanceToggle Livewire component):
|
// 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
|
// 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.
|
// 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> {
|
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
|
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
|
||||||
// emulator/Laravel side expects.
|
// emulator/Laravel side expects.
|
||||||
@@ -56,10 +31,10 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
|
|||||||
const minRank =
|
const minRank =
|
||||||
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
|
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
|
||||||
|
|
||||||
await upsertSetting(KEY_ENABLED, enabled);
|
await executeLegacySystemMutation(actor, "operations.maintenance.update", {
|
||||||
await upsertSetting(KEY_MESSAGE, message);
|
enabled: enabled === "1",
|
||||||
await upsertSetting(KEY_MIN_RANK, String(minRank));
|
message,
|
||||||
|
minimumLoginRank: minRank,
|
||||||
siteSettings.reload();
|
});
|
||||||
revalidatePath("/admin/maintenance");
|
revalidatePath("/admin/maintenance");
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
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 { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
|
||||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
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 { 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({
|
const saveManagedSchema = z.object({
|
||||||
settings: z.record(z.string(), z.string()),
|
settings: z.record(z.string(), z.string()),
|
||||||
@@ -44,79 +19,59 @@ export const saveManagedSettings = adminAction(
|
|||||||
rateLimitMax: 30,
|
rateLimitMax: 30,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const entries = Object.entries(ctx.data.settings)
|
const result = (await executeLegacySystemMutation(
|
||||||
.filter(([key]) => managedKeySet.has(key))
|
{ id: Number(ctx.session.user.id) },
|
||||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
"configuration.settings.save",
|
||||||
await Promise.all(
|
ctx.data,
|
||||||
entries.map(([key, value]) =>
|
)) as { saved: number };
|
||||||
db
|
|
||||||
.insert(WebsiteSetting)
|
|
||||||
.values({ key, value })
|
|
||||||
.onDuplicateKeyUpdate({ set: { value } }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await siteSettings.reload();
|
|
||||||
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
|
||||||
clearOfficialHabboFurnidataCache();
|
|
||||||
clearBadgeCache();
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return actionOk({ saved: entries.length });
|
return actionOk({ saved: result.saved });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function updateSetting(formData: FormData): Promise<void> {
|
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") ?? "")
|
const key = String(formData.get("key") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
const value = normalizeSettingValue(
|
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||||
key,
|
|
||||||
String(formData.get("value") ?? "").normalize("NFC"),
|
|
||||||
);
|
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await db
|
await executeLegacySystemMutation(actor, "configuration.setting.update", {
|
||||||
.insert(WebsiteSetting)
|
key,
|
||||||
.values({ key, value })
|
value,
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
});
|
||||||
await siteSettings.reload();
|
|
||||||
bustGamedataCachesIfNeeded(key);
|
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSetting(formData: FormData): Promise<void> {
|
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") ?? "")
|
const key = String(formData.get("key") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
const value = normalizeSettingValue(
|
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||||
key,
|
|
||||||
String(formData.get("value") ?? "").normalize("NFC"),
|
|
||||||
);
|
|
||||||
const comment = String(formData.get("comment") ?? "")
|
const comment = String(formData.get("comment") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await db
|
await executeLegacySystemMutation(actor, "configuration.setting.create", {
|
||||||
.insert(WebsiteSetting)
|
key,
|
||||||
.values({ key, value, comment: comment || null })
|
value,
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
comment,
|
||||||
await siteSettings.reload();
|
});
|
||||||
bustGamedataCachesIfNeeded(key);
|
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSetting(formData: FormData): Promise<void> {
|
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") ?? "")
|
const key = String(formData.get("key") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
await executeLegacySystemMutation(actor, "configuration.setting.delete", {
|
||||||
await siteSettings.reload();
|
key,
|
||||||
bustGamedataCachesIfNeeded(key);
|
});
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
import {
|
||||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
executeLegacySystemMutation,
|
||||||
|
type SystemMutationOperation,
|
||||||
|
} from "@/features/housekeeping/domains/system/services/mutations";
|
||||||
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 { actionOk } from "@/lib/safe-action-shared";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const PATH = "/admin/commandocentrum";
|
const PATH = "/admin/commandocentrum";
|
||||||
|
|
||||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
async function executeLegacyRcon(
|
||||||
|
ctx: { session: { user: { id: string | number } } },
|
||||||
async function requireRconOk(ok: boolean): Promise<void> {
|
operation: SystemMutationOperation,
|
||||||
if (!ok) throw new ActionError(RCON_FAIL);
|
input: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return executeLegacySystemMutation(
|
||||||
|
{ id: Number(ctx.session.user.id) },
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||||
export const updateCatalog = adminAction(
|
export const updateCatalog = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE },
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
async () => {
|
async (ctx) => {
|
||||||
await requireRconOk(await rcon.updateCatalog());
|
await executeLegacyRcon(ctx, "rcon.update-catalog", {});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -31,8 +37,8 @@ export const updateCatalog = adminAction(
|
|||||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||||
export const updateWordFilter = adminAction(
|
export const updateWordFilter = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE },
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
async () => {
|
async (ctx) => {
|
||||||
await requireRconOk(await rcon.updateWordFilter());
|
await executeLegacyRcon(ctx, "rcon.update-word-filter", {});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -41,8 +47,8 @@ export const updateWordFilter = adminAction(
|
|||||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||||
export const updateNavigator = adminAction(
|
export const updateNavigator = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE },
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
async () => {
|
async (ctx) => {
|
||||||
await requireRconOk(await rcon.send("updatenavigator", null));
|
await executeLegacyRcon(ctx, "rcon.update-navigator", {});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -57,7 +63,7 @@ export const hotelAlert = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const message = ctx.data.message.normalize("NFC");
|
const message = ctx.data.message.normalize("NFC");
|
||||||
await requireRconOk(await rcon.send("hotelalert", { message }));
|
await executeLegacyRcon(ctx, "rcon.hotel-alert", { message });
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -73,7 +79,10 @@ export const disconnectUser = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const username = ctx.data.username.normalize("NFC");
|
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);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -89,7 +98,10 @@ export const alertUser = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const message = ctx.data.message.normalize("NFC");
|
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);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -104,9 +116,7 @@ const forwardUserSchema = z.object({
|
|||||||
export const forwardUser = adminAction(
|
export const forwardUser = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await requireRconOk(
|
await executeLegacyRcon(ctx, "rcon.forward-user", ctx.data);
|
||||||
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
|
|
||||||
);
|
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -121,9 +131,10 @@ const giveCreditsSchema = z.object({
|
|||||||
export const giveCredits = adminAction(
|
export const giveCredits = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await requireRconOk(
|
await executeLegacyRcon(ctx, "rcon.give-credits", {
|
||||||
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
|
userId: ctx.data.userId,
|
||||||
);
|
amount: ctx.data.credits,
|
||||||
|
});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -138,9 +149,7 @@ const giveAmountSchema = z.object({
|
|||||||
export const giveDuckets = adminAction(
|
export const giveDuckets = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await requireRconOk(
|
await executeLegacyRcon(ctx, "rcon.give-duckets", ctx.data);
|
||||||
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
|
|
||||||
);
|
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -150,9 +159,7 @@ export const giveDuckets = adminAction(
|
|||||||
export const giveDiamonds = adminAction(
|
export const giveDiamonds = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await requireRconOk(
|
await executeLegacyRcon(ctx, "rcon.give-diamonds", ctx.data);
|
||||||
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
|
|
||||||
);
|
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -168,7 +175,10 @@ export const giveBadge = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const badge = ctx.data.badge.normalize("NFC");
|
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);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -184,7 +194,10 @@ export const setMotto = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const motto = ctx.data.motto.normalize("NFC");
|
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);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -199,28 +212,9 @@ 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 result = await systemMutationService.execute(
|
const result = await executeLegacyRcon(ctx, "rcon.set-rank", ctx.data);
|
||||||
{
|
|
||||||
capability: createHousekeepingCapabilityContext(
|
|
||||||
{
|
|
||||||
id: Number(ctx.session.user.id),
|
|
||||||
username: ctx.session.user.username,
|
|
||||||
rank: Number(ctx.session.user.rank),
|
|
||||||
},
|
|
||||||
ctx.permissions,
|
|
||||||
),
|
|
||||||
correlationId: crypto.randomUUID(),
|
|
||||||
},
|
|
||||||
"rcon.set-rank",
|
|
||||||
ctx.data,
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError(result.error.messageKey);
|
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
if (result.completion)
|
return actionOk(result as Record<string, unknown>);
|
||||||
throw new ActionError(
|
|
||||||
`Rank saved; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`,
|
|
||||||
);
|
|
||||||
return actionOk(result.data as Record<string, unknown>);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -234,7 +228,10 @@ export const executeCommand = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const command = ctx.data.command.normalize("NFC");
|
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);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -251,9 +248,11 @@ export const sendGift = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
|
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
|
||||||
await requireRconOk(
|
await executeLegacyRcon(ctx, "rcon.send-gift", {
|
||||||
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
|
userId: ctx.data.userId,
|
||||||
);
|
itemId: ctx.data.itemId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const { insertValues } = vi.hoisted(() => {
|
const { executeSystem } = vi.hoisted(() => ({ executeSystem: vi.fn() }));
|
||||||
const insertValues = vi.fn(() => ({
|
|
||||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
|
||||||
}));
|
|
||||||
return { insertValues };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/db", () => ({
|
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
|
||||||
db: {
|
executeLegacySystemMutation: executeSystem,
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
},
|
|
||||||
EmulatorSettings: { key: "key", value: "value" },
|
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
adminAction: vi.fn(
|
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/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", () => {
|
describe("saveEmulatorSettings", () => {
|
||||||
it("saves settings and calls rcon update", async () => {
|
it("saves settings and calls rcon update", async () => {
|
||||||
|
executeSystem.mockResolvedValue({ saved: 2 });
|
||||||
const handler = (await import("./emulator").then(
|
const handler = (await import("./emulator").then(
|
||||||
(m) => m.saveEmulatorSettings,
|
(m) => m.saveEmulatorSettings,
|
||||||
)) as unknown as (ctx: {
|
)) as unknown as (ctx: {
|
||||||
@@ -41,8 +31,11 @@ describe("saveEmulatorSettings", () => {
|
|||||||
session: { user: { id: "1" } },
|
session: { user: { id: "1" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(insertValues).toHaveBeenCalledTimes(2);
|
expect(executeSystem).toHaveBeenCalledWith(
|
||||||
expect(rcon.updateConfig).toHaveBeenCalled();
|
{ id: 1 },
|
||||||
|
"configuration.emulator-settings.save",
|
||||||
|
{ settings: { key1: "val1", key2: "val2" } },
|
||||||
|
);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+6
-20
@@ -1,12 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from "@/lib/safe-action-shared";
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const saveEmulatorSettingsSchema = z.object({
|
const saveEmulatorSettingsSchema = z.object({
|
||||||
settings: z.record(z.string(), z.string()),
|
settings: z.record(z.string(), z.string()),
|
||||||
@@ -15,23 +13,11 @@ const saveEmulatorSettingsSchema = z.object({
|
|||||||
export const saveEmulatorSettings = adminAction(
|
export const saveEmulatorSettings = adminAction(
|
||||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const entries = Object.entries(ctx.data.settings);
|
await executeLegacySystemMutation(
|
||||||
|
{ id: Number(ctx.session.user.id) },
|
||||||
for (const [key, value] of entries) {
|
"configuration.emulator-settings.save",
|
||||||
await db
|
ctx.data,
|
||||||
.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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-187
@@ -1,22 +1,10 @@
|
|||||||
"use server";
|
"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 { z } from "zod";
|
||||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations";
|
||||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
|
||||||
import { AclModelPermission, AclPermission, AclRole, db } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { 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 { 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";
|
|
||||||
|
|
||||||
const createRankSchema = z.object({
|
const createRankSchema = z.object({
|
||||||
rank_name: z.string().trim().min(1).max(25),
|
rank_name: z.string().trim().min(1).max(25),
|
||||||
@@ -26,25 +14,12 @@ const createRankSchema = z.object({
|
|||||||
export const createRank = adminAction(
|
export const createRank = adminAction(
|
||||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = await createEmulatorRank(db, ctx.data);
|
const result = (await executeLegacySystemMutation(
|
||||||
await db
|
{ id: Number(ctx.session.user.id) },
|
||||||
.insert(AclRole)
|
"access.rank.create",
|
||||||
.values({
|
{ name: ctx.data.rank_name, level: ctx.data.level },
|
||||||
slug: `rank_${id}`,
|
)) as { id: number };
|
||||||
title: ctx.data.rank_name,
|
return actionOk({ id: result.id });
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,27 +28,11 @@ 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 result = await systemMutationService.execute(
|
await executeLegacySystemMutation(
|
||||||
{
|
{ id: Number(ctx.session.user.id) },
|
||||||
capability: createHousekeepingCapabilityContext(
|
|
||||||
{
|
|
||||||
id: Number(ctx.session.user.id),
|
|
||||||
username: ctx.session.user.username,
|
|
||||||
rank: Number(ctx.session.user.rank),
|
|
||||||
},
|
|
||||||
ctx.permissions,
|
|
||||||
),
|
|
||||||
correlationId: crypto.randomUUID(),
|
|
||||||
},
|
|
||||||
"access.rank.delete",
|
"access.rank.delete",
|
||||||
ctx.data,
|
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();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -86,22 +45,11 @@ const saveRankSchema = z.object({
|
|||||||
export const saveRank = adminAction(
|
export const saveRank = adminAction(
|
||||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
|
await executeLegacySystemMutation(
|
||||||
if (typeof ctx.data.fields.rank_name === "string") {
|
{ id: Number(ctx.session.user.id) },
|
||||||
await db
|
"access.rank.update",
|
||||||
.update(AclRole)
|
ctx.data,
|
||||||
.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 });
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -114,43 +62,11 @@ const setCmsPermsSchema = z.object({
|
|||||||
export const setCmsPermissions = adminAction(
|
export const setCmsPermissions = adminAction(
|
||||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [role] = await db
|
await executeLegacySystemMutation(
|
||||||
.select({ id: AclRole.id, slug: AclRole.slug })
|
{ id: Number(ctx.session.user.id) },
|
||||||
.from(AclRole)
|
"access.permissions.update",
|
||||||
.where(eq(AclRole.id, ctx.data.roleId))
|
ctx.data,
|
||||||
.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"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
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();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -164,88 +80,11 @@ export const setCmsPermissions = adminAction(
|
|||||||
export const repairAdminNavAclGrants = adminAction(
|
export const repairAdminNavAclGrants = adminAction(
|
||||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [dashboardFillResult] = await db.execute(sql`
|
const result = (await executeLegacySystemMutation(
|
||||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
{ id: Number(ctx.session.user.id) },
|
||||||
SELECT 'Role', ar.id, ap.id
|
"access.permissions.repair",
|
||||||
FROM \`acl_roles\` ar
|
{},
|
||||||
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
)) as { inserted: number };
|
||||||
WHERE EXISTS (
|
return actionOk({ inserted: result.inserted });
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -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.access.") ||
|
||||||
id.startsWith("system.configuration.setting") ||
|
id.startsWith("system.configuration.setting") ||
|
||||||
id === "system.configuration.settings.save" ||
|
id === "system.configuration.settings.save" ||
|
||||||
|
id === "system.configuration.emulator-setting.update" ||
|
||||||
|
id === "system.configuration.emulator-text.update" ||
|
||||||
id === "system.operations.alert.broadcast" ||
|
id === "system.operations.alert.broadcast" ||
|
||||||
id.startsWith("system.operations.rcon.") ||
|
id.startsWith("system.operations.rcon.") ||
|
||||||
id === "system.operations.maintenance.update",
|
id === "system.operations.maintenance.update",
|
||||||
|
|||||||
@@ -172,14 +172,14 @@ export function createSystemCommands(
|
|||||||
operation: "configuration.emulator-setting.update",
|
operation: "configuration.emulator-setting.update",
|
||||||
capability: PERMS.SETTINGS_EDIT,
|
capability: PERMS.SETTINGS_EDIT,
|
||||||
input: z.object({ key: requiredText(100), value: z.string().max(512) }),
|
input: z.object({ key: requiredText(100), value: z.string().max(512) }),
|
||||||
requiresReason: false,
|
requiresReason: true,
|
||||||
}),
|
}),
|
||||||
systemCommand(service, {
|
systemCommand(service, {
|
||||||
id: "system.configuration.emulator-text.update",
|
id: "system.configuration.emulator-text.update",
|
||||||
operation: "configuration.emulator-text.update",
|
operation: "configuration.emulator-text.update",
|
||||||
capability: PERMS.SETTINGS_EDIT,
|
capability: PERMS.SETTINGS_EDIT,
|
||||||
input: z.object({ key: requiredText(100), value: z.string().max(4096) }),
|
input: z.object({ key: requiredText(100), value: z.string().max(4096) }),
|
||||||
requiresReason: false,
|
requiresReason: true,
|
||||||
}),
|
}),
|
||||||
systemCommand(service, {
|
systemCommand(service, {
|
||||||
id: "system.operations.alerts.mark-read",
|
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 { PERMS } from "@/lib/permission-slugs";
|
||||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
|
||||||
const { markReadWhere, rconSend } = vi.hoisted(() => ({
|
const { logAudit, markReadWhere, rconSend } = vi.hoisted(() => ({
|
||||||
|
logAudit: vi.fn(),
|
||||||
markReadWhere: vi.fn(),
|
markReadWhere: vi.fn(),
|
||||||
rconSend: vi.fn(),
|
rconSend: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit }));
|
||||||
|
|
||||||
vi.mock("@/lib/db", async (importOriginal) => {
|
vi.mock("@/lib/db", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||||
return {
|
return {
|
||||||
@@ -47,6 +50,8 @@ describe("System production mutation failures", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
markReadWhere.mockReset();
|
markReadWhere.mockReset();
|
||||||
rconSend.mockReset();
|
rconSend.mockReset();
|
||||||
|
logAudit.mockReset();
|
||||||
|
logAudit.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps a failed alert broadcast to dependency unavailable", async () => {
|
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 () => {
|
it("maps mark-read persistence failure to dependency unavailable", async () => {
|
||||||
markReadWhere.mockRejectedValue(new Error("database unavailable"));
|
markReadWhere.mockRejectedValue(new Error("database unavailable"));
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@ const doubles = vi.hoisted(() => ({
|
|||||||
dbUpdate: vi.fn(),
|
dbUpdate: vi.fn(),
|
||||||
deleteEmulatorRank: vi.fn(),
|
deleteEmulatorRank: vi.fn(),
|
||||||
deleteEmulatorRankInTransaction: vi.fn(),
|
deleteEmulatorRankInTransaction: vi.fn(),
|
||||||
|
fetchEmulatorRankForEdit: vi.fn(),
|
||||||
logAudit: vi.fn(),
|
logAudit: vi.fn(),
|
||||||
logStaffActivity: vi.fn(),
|
logStaffActivity: vi.fn(),
|
||||||
logStaffActivityInTransaction: vi.fn(),
|
logStaffActivityInTransaction: vi.fn(),
|
||||||
@@ -57,10 +58,19 @@ vi.mock("@/lib/services/audit", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/permission-ranks", () => ({
|
vi.mock("@/lib/services/permission-ranks", () => ({
|
||||||
|
RANK_GENERAL_FIELDS: new Set([
|
||||||
|
"rank_name",
|
||||||
|
"badge",
|
||||||
|
"level",
|
||||||
|
"prefix",
|
||||||
|
"prefix_color",
|
||||||
|
"hidden_rank",
|
||||||
|
]),
|
||||||
createEmulatorRank: doubles.createEmulatorRank,
|
createEmulatorRank: doubles.createEmulatorRank,
|
||||||
createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction,
|
createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction,
|
||||||
deleteEmulatorRank: doubles.deleteEmulatorRank,
|
deleteEmulatorRank: doubles.deleteEmulatorRank,
|
||||||
deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction,
|
deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction,
|
||||||
|
fetchEmulatorRankForEdit: doubles.fetchEmulatorRankForEdit,
|
||||||
prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation,
|
prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation,
|
||||||
updateEmulatorRank: doubles.updateEmulatorRank,
|
updateEmulatorRank: doubles.updateEmulatorRank,
|
||||||
}));
|
}));
|
||||||
@@ -142,11 +152,29 @@ beforeEach(() => {
|
|||||||
doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined);
|
doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined);
|
||||||
doubles.deleteEmulatorRank.mockResolvedValue(undefined);
|
doubles.deleteEmulatorRank.mockResolvedValue(undefined);
|
||||||
doubles.deleteEmulatorRankInTransaction.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.updateEmulatorRank.mockResolvedValue(undefined);
|
||||||
doubles.logAudit.mockResolvedValue(undefined);
|
doubles.logAudit.mockResolvedValue(undefined);
|
||||||
doubles.logStaffActivity.mockResolvedValue(undefined);
|
doubles.logStaffActivity.mockResolvedValue(undefined);
|
||||||
doubles.logStaffActivityInTransaction.mockResolvedValue(undefined);
|
doubles.logStaffActivityInTransaction.mockResolvedValue(undefined);
|
||||||
doubles.dbDelete.mockImplementation(deleteChain);
|
doubles.dbDelete.mockImplementation(deleteChain);
|
||||||
|
doubles.dbExecute.mockResolvedValue([[{ id: 7 }]]);
|
||||||
doubles.dbInsert.mockImplementation(insertChain);
|
doubles.dbInsert.mockImplementation(insertChain);
|
||||||
doubles.dbTransaction.mockImplementation(async (run) =>
|
doubles.dbTransaction.mockImplementation(async (run) =>
|
||||||
run(transactionToken),
|
run(transactionToken),
|
||||||
@@ -186,6 +214,45 @@ function expectPendingSynchronization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("durable rank synchronization", () => {
|
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([
|
it.each([
|
||||||
["access.rank.delete", PERMS.PERMISSIONS_MANAGE, { id: 7 }],
|
["access.rank.delete", PERMS.PERMISSIONS_MANAGE, { id: 7 }],
|
||||||
["rcon.set-rank", PERMS.RCON_EXECUTE, { userId: 8, rank: 4 }],
|
["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", () => {
|
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 () => {
|
it("returns a typed not-found result for an unknown command", async () => {
|
||||||
const result = await dispatchHousekeepingCommand(
|
const result = await dispatchHousekeepingCommand(
|
||||||
{ commandId: "system.missing", input: {} },
|
{ commandId: "system.missing", input: {} },
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const housekeepingResultSchema = z.discriminatedUnion("ok", [
|
|||||||
status: z.literal("partial"),
|
status: z.literal("partial"),
|
||||||
external: z.enum(["not-required", "completed", "failed"]),
|
external: z.enum(["not-required", "completed", "failed"]),
|
||||||
audit: z.enum(["persisted", "unavailable"]),
|
audit: z.enum(["persisted", "unavailable"]),
|
||||||
|
cache: z.enum(["invalidated", "unavailable"]).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
describe("ACL management contract", () => {
|
describe("ACL management contract", () => {
|
||||||
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
||||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
const actionSource = readFileSync("src/actions/permissions.ts", "utf8");
|
||||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
const serviceSource = readFileSync(
|
||||||
expect(source).toContain("adminAction");
|
"src/features/housekeeping/domains/system/services/mutations.ts",
|
||||||
expect(source).toContain("AclModelPermission");
|
"utf8",
|
||||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
);
|
||||||
expect(source).not.toContain("websiteTeams");
|
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", () => {
|
it("ships an idempotent ACL completion migration", () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
|||||||
peopleMutationService: { execute: doubles.people },
|
peopleMutationService: { execute: doubles.people },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
|
||||||
systemMutationService: { execute: doubles.system },
|
executeLegacySystemMutation: doubles.system,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||||
|
|
||||||
@@ -54,9 +54,7 @@ beforeEach(() => {
|
|||||||
correlationId: "operation-42",
|
correlationId: "operation-42",
|
||||||
});
|
});
|
||||||
doubles.system.mockResolvedValue({
|
doubles.system.mockResolvedValue({
|
||||||
ok: true,
|
rank: 4,
|
||||||
data: { rank: 4 },
|
|
||||||
correlationId: "operation-42",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,30 +62,28 @@ describe("legacy rank entrypoints", () => {
|
|||||||
it("delegates legacy rank deletion to the same System transaction", async () => {
|
it("delegates legacy rank deletion to the same System transaction", async () => {
|
||||||
await deleteRank({ ...context, data: { id: 4 } } as never);
|
await deleteRank({ ...context, data: { id: 4 } } as never);
|
||||||
expect(doubles.system).toHaveBeenCalledWith(
|
expect(doubles.system).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
{ id: 42 },
|
||||||
capability: expect.objectContaining({ actor: context.session.user }),
|
|
||||||
}),
|
|
||||||
"access.rank.delete",
|
"access.rank.delete",
|
||||||
{ id: 4 },
|
{ id: 4 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("routes command-centre assignments through the authorized System service", async () => {
|
it("routes command-centre assignments through the authorized System service", async () => {
|
||||||
await setRank({ ...context, data: { userId: 8, rank: 4 } } as never);
|
await setRank({ ...context, data: { userId: 8, rank: 4 } } as never);
|
||||||
expect(doubles.system).toHaveBeenCalledWith(
|
expect(doubles.system).toHaveBeenCalledWith({ id: 42 }, "rcon.set-rank", {
|
||||||
expect.objectContaining({
|
userId: 8,
|
||||||
capability: expect.objectContaining({ actor: context.session.user }),
|
rank: 4,
|
||||||
correlationId: expect.any(String),
|
});
|
||||||
}),
|
|
||||||
"rcon.set-rank",
|
|
||||||
{ userId: 8, rank: 4 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not present a partially synchronized command-centre assignment as complete", async () => {
|
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(
|
await expect(
|
||||||
setRank({ ...context, data: { userId: 8, rank: 4 } } as never),
|
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 () => {
|
it("routes the old user editor through People, preserving fields and actor identity", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user