448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import { dispatchHousekeepingCommand } from "../../../foundation/commands/dispatcher";
|
|
import {
|
|
type HousekeepingCommand,
|
|
registerHousekeepingCommand,
|
|
} from "../../../foundation/commands/registry";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
|
|
const doubles = vi.hoisted(() => ({
|
|
createEmulatorRank: vi.fn(),
|
|
createEmulatorRankInTransaction: vi.fn(),
|
|
dbDelete: vi.fn(),
|
|
dbExecute: vi.fn(),
|
|
dbForUpdate: vi.fn(),
|
|
dbInsert: vi.fn(),
|
|
dbSelect: vi.fn(),
|
|
dbTransaction: vi.fn(),
|
|
dbUpdate: vi.fn(),
|
|
deleteEmulatorRank: vi.fn(),
|
|
deleteEmulatorRankInTransaction: vi.fn(),
|
|
logAudit: vi.fn(),
|
|
logStaffActivity: vi.fn(),
|
|
logStaffActivityInTransaction: vi.fn(),
|
|
prepareEmulatorRankCreation: vi.fn(),
|
|
rconSend: vi.fn(),
|
|
rconSetRank: vi.fn(),
|
|
updateEmulatorRank: vi.fn(),
|
|
}));
|
|
|
|
const transactionToken = {
|
|
delete: doubles.dbDelete,
|
|
execute: doubles.dbExecute,
|
|
insert: doubles.dbInsert,
|
|
select: doubles.dbSelect,
|
|
update: doubles.dbUpdate,
|
|
};
|
|
|
|
vi.mock("@/lib/db", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/db")>();
|
|
return {
|
|
...actual,
|
|
db: {
|
|
...actual.db,
|
|
delete: doubles.dbDelete,
|
|
execute: doubles.dbExecute,
|
|
insert: doubles.dbInsert,
|
|
select: doubles.dbSelect,
|
|
transaction: doubles.dbTransaction,
|
|
update: doubles.dbUpdate,
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/services/audit", () => ({
|
|
logAudit: doubles.logAudit,
|
|
}));
|
|
|
|
vi.mock("@/lib/services/permission-ranks", () => ({
|
|
createEmulatorRank: doubles.createEmulatorRank,
|
|
createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction,
|
|
deleteEmulatorRank: doubles.deleteEmulatorRank,
|
|
deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction,
|
|
prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation,
|
|
updateEmulatorRank: doubles.updateEmulatorRank,
|
|
}));
|
|
|
|
vi.mock("@/lib/services/rcon", () => ({
|
|
rcon: { send: doubles.rconSend, setRank: doubles.rconSetRank },
|
|
}));
|
|
|
|
vi.mock("@/lib/services/staff-activity", () => ({
|
|
logStaffActivity: doubles.logStaffActivity,
|
|
logStaffActivityInTransaction: doubles.logStaffActivityInTransaction,
|
|
}));
|
|
|
|
import { createSystemCommands } from "../commands/system-commands";
|
|
import {
|
|
type SystemMutationOperation,
|
|
systemMutationService,
|
|
} from "./mutations";
|
|
|
|
function capabilityContext(
|
|
granted: readonly string[],
|
|
): HousekeepingCapabilityContext {
|
|
const permissions = new Set(granted);
|
|
return {
|
|
actor: { id: 42, username: "operator", rank: 500 },
|
|
isSuperAdmin: false,
|
|
has: (slug) => permissions.has(slug),
|
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
|
};
|
|
}
|
|
|
|
function serviceContext(permission: string) {
|
|
return {
|
|
capability: capabilityContext([permission]),
|
|
correlationId: "rank-mutation-correlation",
|
|
reason: "Approved rank lifecycle change",
|
|
};
|
|
}
|
|
|
|
function insertChain() {
|
|
return {
|
|
values: () => ({
|
|
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function deleteChain() {
|
|
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
}
|
|
|
|
function updateChain(result: Promise<unknown> = Promise.resolve(undefined)) {
|
|
return { set: () => ({ where: () => result }) };
|
|
}
|
|
|
|
function limitedSelection(rows: readonly unknown[]) {
|
|
doubles.dbForUpdate.mockResolvedValueOnce(rows);
|
|
return {
|
|
from: () => ({
|
|
where: () => ({
|
|
for: doubles.dbForUpdate,
|
|
limit: () => Promise.resolve(rows),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function plainSelection(rows: readonly unknown[]) {
|
|
return {
|
|
from: () => ({ where: () => Promise.resolve(rows) }),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
for (const mock of Object.values(doubles)) mock.mockReset();
|
|
doubles.createEmulatorRank.mockResolvedValue(7);
|
|
doubles.prepareEmulatorRankCreation.mockResolvedValue(7);
|
|
doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined);
|
|
doubles.deleteEmulatorRank.mockResolvedValue(undefined);
|
|
doubles.deleteEmulatorRankInTransaction.mockResolvedValue(undefined);
|
|
doubles.updateEmulatorRank.mockResolvedValue(undefined);
|
|
doubles.logAudit.mockResolvedValue(undefined);
|
|
doubles.logStaffActivity.mockResolvedValue(undefined);
|
|
doubles.logStaffActivityInTransaction.mockResolvedValue(undefined);
|
|
doubles.dbDelete.mockImplementation(deleteChain);
|
|
doubles.dbInsert.mockImplementation(insertChain);
|
|
doubles.dbTransaction.mockImplementation(async (run) =>
|
|
run(transactionToken),
|
|
);
|
|
doubles.dbUpdate.mockImplementation(() => updateChain());
|
|
});
|
|
|
|
function expectPendingSynchronization(
|
|
result: unknown,
|
|
operation:
|
|
| "access.rank.create"
|
|
| "access.rank.delete"
|
|
| "access.rank.update"
|
|
| "rcon.set-rank",
|
|
) {
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
operation,
|
|
recoveryId: expect.any(String),
|
|
completed: ["database", "audit"],
|
|
},
|
|
completion: {
|
|
status: "partial",
|
|
external: "failed",
|
|
audit: "persisted",
|
|
},
|
|
});
|
|
if (typeof result === "object" && result !== null && "ok" in result) {
|
|
const typed = result as {
|
|
ok: boolean;
|
|
correlationId?: string;
|
|
data?: { recoveryId?: string };
|
|
};
|
|
if (typed.ok) expect(typed.data?.recoveryId).toBe(typed.correlationId);
|
|
}
|
|
}
|
|
|
|
describe("durable rank synchronization", () => {
|
|
it("rolls back before RCON when the transaction-bound recovery intent cannot be audited", async () => {
|
|
doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable"));
|
|
doubles.rconSend.mockResolvedValue(true);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.PERMISSIONS_MANAGE),
|
|
"access.rank.create",
|
|
{ name: "Administrator", level: 7 },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
expect(doubles.rconSend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("commits create, ACL, staff audit, and recovery intent together before returning a retry key", async () => {
|
|
doubles.rconSend.mockResolvedValue(false);
|
|
const command = createSystemCommands(systemMutationService).find(
|
|
(entry) => entry.id === "system.access.rank.create",
|
|
) as
|
|
| HousekeepingCommand<{ name: string; level: number }, unknown>
|
|
| undefined;
|
|
if (!command) throw new Error("rank create command missing");
|
|
registerHousekeepingCommand(command);
|
|
const outcomes: string[] = [];
|
|
|
|
const result = await dispatchHousekeepingCommand(
|
|
{
|
|
commandId: command.id,
|
|
input: { name: "Administrator", level: 7 },
|
|
reason: "Create the administrator rank",
|
|
},
|
|
{
|
|
context: capabilityContext([PERMS.PERMISSIONS_MANAGE]),
|
|
ipAddress: "198.51.100.8",
|
|
audit: {
|
|
write: async (entry) => {
|
|
if (entry.outcome) outcomes.push(entry.outcome);
|
|
},
|
|
},
|
|
rateLimit: async () => true,
|
|
},
|
|
);
|
|
|
|
expectPendingSynchronization(result, "access.rank.create");
|
|
expect(outcomes).toEqual(["intent", "partial"]);
|
|
expect(doubles.createEmulatorRankInTransaction).toHaveBeenCalledWith(
|
|
transactionToken,
|
|
7,
|
|
{ rank_name: "Administrator", level: 7 },
|
|
);
|
|
expect(doubles.logStaffActivityInTransaction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "rank_create", targetId: 7 }),
|
|
transactionToken,
|
|
);
|
|
expect(doubles.logAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
correlationId: expect.any(String),
|
|
outcome: "intent",
|
|
}),
|
|
transactionToken,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"access.rank.delete",
|
|
{ id: 7 },
|
|
() => {
|
|
doubles.dbSelect
|
|
.mockImplementationOnce(() => plainSelection([{ total: 0 }]))
|
|
.mockImplementationOnce(() => limitedSelection([]));
|
|
},
|
|
"deleteEmulatorRankInTransaction",
|
|
],
|
|
[
|
|
"access.rank.update",
|
|
{ id: 7, fields: { badge: "ADM" } },
|
|
() => {},
|
|
"updateEmulatorRank",
|
|
],
|
|
] as const)(
|
|
"returns a recoverable partial after atomic %s commit when cache refresh fails",
|
|
async (operation, input, prepare, helperName) => {
|
|
prepare();
|
|
doubles.rconSend.mockResolvedValue(false);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.PERMISSIONS_MANAGE),
|
|
operation as SystemMutationOperation,
|
|
input,
|
|
);
|
|
|
|
expectPendingSynchronization(result, operation);
|
|
expect(doubles[helperName]).toHaveBeenCalledWith(
|
|
transactionToken,
|
|
7,
|
|
...(operation === "access.rank.update" ? [{ badge: "ADM" }] : []),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("never calls RCON when set-rank database persistence rolls back", async () => {
|
|
doubles.dbSelect.mockImplementationOnce(() =>
|
|
limitedSelection([{ rank: 3 }]),
|
|
);
|
|
doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]);
|
|
doubles.dbUpdate.mockImplementationOnce(() =>
|
|
updateChain(Promise.reject(new Error("users update unavailable"))),
|
|
);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.RCON_EXECUTE),
|
|
"rcon.set-rank",
|
|
{ userId: 8, rank: 4 },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
expect(doubles.rconSetRank).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("locks the user row before committing a set-rank intent", async () => {
|
|
doubles.dbSelect.mockImplementationOnce(() =>
|
|
limitedSelection([{ rank: 3 }]),
|
|
);
|
|
doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]);
|
|
doubles.rconSetRank.mockResolvedValue(true);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.RCON_EXECUTE),
|
|
"rcon.set-rank",
|
|
{ userId: 8, rank: 4 },
|
|
);
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
expect(doubles.dbForUpdate).toHaveBeenCalledWith("update");
|
|
});
|
|
|
|
it("returns the durable recovery key when set-rank RCON fails after commit", async () => {
|
|
doubles.dbSelect.mockImplementationOnce(() =>
|
|
limitedSelection([{ rank: 3 }]),
|
|
);
|
|
doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]);
|
|
doubles.rconSetRank.mockResolvedValue(false);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.RCON_EXECUTE),
|
|
"rcon.set-rank",
|
|
{ userId: 8, rank: 4 },
|
|
);
|
|
|
|
expectPendingSynchronization(result, "rcon.set-rank");
|
|
expect(doubles.dbUpdate).toHaveBeenCalledBefore(doubles.rconSetRank);
|
|
});
|
|
|
|
it("retries only the convergent cache refresh, never the original rank mutation", async () => {
|
|
doubles.dbExecute.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
after: JSON.stringify({
|
|
kind: "rank-permission-cache",
|
|
operation: "access.rank.create",
|
|
rankId: 7,
|
|
}),
|
|
outcome: "partial",
|
|
},
|
|
],
|
|
]);
|
|
doubles.rconSend.mockResolvedValue(true);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.PERMISSIONS_MANAGE),
|
|
"access.rank.sync.retry" as SystemMutationOperation,
|
|
{ recoveryId: "original-rank-correlation" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
recoveryId: "original-rank-correlation",
|
|
synchronization: "completed",
|
|
},
|
|
});
|
|
expect(doubles.rconSend).toHaveBeenCalledWith("updatepermissions");
|
|
expect(doubles.createEmulatorRank).not.toHaveBeenCalled();
|
|
expect(doubles.createEmulatorRankInTransaction).not.toHaveBeenCalled();
|
|
expect(doubles.deleteEmulatorRank).not.toHaveBeenCalled();
|
|
expect(doubles.deleteEmulatorRankInTransaction).not.toHaveBeenCalled();
|
|
expect(doubles.updateEmulatorRank).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries set-rank from its committed desired-state intent without another database mutation", async () => {
|
|
doubles.dbExecute.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
after: JSON.stringify({
|
|
kind: "set-rank",
|
|
operation: "rcon.set-rank",
|
|
userId: 8,
|
|
rank: 4,
|
|
}),
|
|
outcome: "intent",
|
|
},
|
|
],
|
|
]);
|
|
doubles.rconSetRank.mockResolvedValue(true);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.RCON_EXECUTE),
|
|
"rcon.set-rank.retry" as SystemMutationOperation,
|
|
{ recoveryId: "original-set-rank-correlation" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
recoveryId: "original-set-rank-correlation",
|
|
synchronization: "completed",
|
|
},
|
|
});
|
|
expect(doubles.rconSetRank).toHaveBeenCalledWith(8, 4);
|
|
expect(doubles.dbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not repeat an already completed external synchronization", async () => {
|
|
doubles.dbExecute.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
after: JSON.stringify({
|
|
kind: "rank-permission-cache",
|
|
operation: "access.rank.update",
|
|
rankId: 7,
|
|
}),
|
|
outcome: "success",
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await systemMutationService.execute(
|
|
serviceContext(PERMS.PERMISSIONS_MANAGE),
|
|
"access.rank.sync.retry",
|
|
{ recoveryId: "completed-rank-correlation" },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
alreadyCompleted: true,
|
|
recoveryId: "completed-rank-correlation",
|
|
},
|
|
});
|
|
expect(doubles.rconSend).not.toHaveBeenCalled();
|
|
});
|
|
});
|