WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"));
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 }],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user