fix(housekeeping): complete people workflow fidelity
This commit is contained in:
1 parent
25b76437ff
commit
91c9efcbb4
29 files changed
+1365
-290
No files matched your search
+54
-47
@@ -3,34 +3,25 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const { alertUser, audit, resolveServerContext, selectLimit } = vi.hoisted(
|
||||
() => ({
|
||||
alertUser: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
resolveServerContext: vi.fn(),
|
||||
selectLimit: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { alertUser, audit, resolveServerContext } = vi.hoisted(() => ({
|
||||
alertUser: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
resolveServerContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: resolveServerContext,
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: selectLimit })),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/db", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/db")>()),
|
||||
db: {
|
||||
select: vi.fn(() => {
|
||||
throw new Error("alert must not query DB");
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: audit,
|
||||
@@ -61,17 +52,6 @@ const invocation = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveServerContext.mockResolvedValue(context());
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
rank: 2,
|
||||
motto: "Ready",
|
||||
credits: 100,
|
||||
pixels: 50,
|
||||
online: "1",
|
||||
},
|
||||
]);
|
||||
alertUser.mockResolvedValue(true);
|
||||
audit.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -93,7 +73,7 @@ describe("People external audit contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists a correlated failure outcome when the external operation fails", async () => {
|
||||
it("persists a correlated failure outcome when a pre-mutation external operation fails", async () => {
|
||||
alertUser.mockResolvedValue(false);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
@@ -120,24 +100,51 @@ describe("People external audit contract", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws typed completed-operation evidence when final outcome persistence fails", async () => {
|
||||
it("returns serializable partial completion when final outcome persistence fails", async () => {
|
||||
audit.mockImplementation(async (entry: AuditEntry) => {
|
||||
if (entry.outcome === "success") throw new Error("outcome unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "user.alert", {
|
||||
userId: 7,
|
||||
message: "Hello",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "AuditOutcomePersistenceError",
|
||||
operationCompleted: true,
|
||||
operationResult: {
|
||||
before: expect.objectContaining({ id: 7 }),
|
||||
after: expect.objectContaining({ alertDelivered: true }),
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "completed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: {
|
||||
before: null,
|
||||
after: { userId: 7, alertDelivered: true },
|
||||
},
|
||||
correlationId: "audit-contract",
|
||||
});
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
expect(alertUser).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "success", "partial"]);
|
||||
});
|
||||
|
||||
it("attempts legacy alert without a target lookup when the database is unavailable", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, legacy: true },
|
||||
"user.alert",
|
||||
{ userId: 900719925, message: "Direct RCON" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
before: null,
|
||||
after: { userId: 900719925, alertDelivered: true },
|
||||
},
|
||||
});
|
||||
expect(alertUser).toHaveBeenCalledTimes(1);
|
||||
expect(alertUser).toHaveBeenCalledWith(900719925, "Direct RCON");
|
||||
});
|
||||
});
|
||||
+539
-4
@@ -5,8 +5,11 @@ import type { HousekeepingCapabilityContext } from "../../../foundation/contract
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
hashPassword: vi.fn(),
|
||||
invalidateLoginCache: vi.fn(),
|
||||
insertValues: vi.fn(),
|
||||
logStaffActivity: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
rcon: {
|
||||
alertUser: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
@@ -14,7 +17,9 @@ const mocks = vi.hoisted(() => ({
|
||||
giveCredits: vi.fn(),
|
||||
giveDuckets: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
muteUser: vi.fn(),
|
||||
setTradeLock: vi.fn(),
|
||||
unmuteUser: vi.fn(),
|
||||
updateWordFilter: vi.fn(),
|
||||
},
|
||||
reloadSettings: vi.fn(),
|
||||
@@ -22,6 +27,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveServerContext: vi.fn(),
|
||||
selectQueue: [] as unknown[][],
|
||||
transaction: vi.fn(),
|
||||
updateSet: vi.fn(),
|
||||
updateWhere: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -56,7 +62,7 @@ function databaseFacade() {
|
||||
values: mocks.insertValues.mockImplementation(() => mutationPromise()),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
set: mocks.updateSet.mockImplementation(() => ({
|
||||
where: mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]),
|
||||
})),
|
||||
})),
|
||||
@@ -70,8 +76,12 @@ function databaseFacade() {
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: mocks.resolveServerContext,
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
invalidateLoginCache: mocks.invalidateLoginCache,
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: mocks.hashPassword,
|
||||
}));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
const tx = databaseFacade();
|
||||
@@ -112,7 +122,7 @@ vi.mock("@/lib/services/site-settings", () => ({
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: mocks.logStaffActivity,
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: mocks.notify }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
@@ -132,11 +142,25 @@ const invocation = {
|
||||
legacy: true,
|
||||
};
|
||||
|
||||
function target(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
rank: 2,
|
||||
motto: "Original",
|
||||
credits: 10,
|
||||
pixels: 20,
|
||||
online: "1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.selectQueue.length = 0;
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.hashPassword.mockResolvedValue("hashed-password");
|
||||
mocks.reloadSettings.mockResolvedValue(undefined);
|
||||
for (const call of Object.values(mocks.rcon)) call.mockResolvedValue(true);
|
||||
});
|
||||
@@ -197,6 +221,37 @@ describe("People production workflow adapter", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps absent trade settings null before and after a no-op settings update", async () => {
|
||||
mocks.selectQueue.push(
|
||||
[
|
||||
{
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
rank: 2,
|
||||
motto: "",
|
||||
credits: 0,
|
||||
pixels: 0,
|
||||
online: "0",
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.trade-lock",
|
||||
{ userId: 7, untilUnix: 200 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { canTrade: null, tradelockAmount: null },
|
||||
after: { canTrade: null, tradelockAmount: null },
|
||||
},
|
||||
});
|
||||
});
|
||||
it("runs guild, application, team, and IP database workflows transactionally", async () => {
|
||||
mocks.selectQueue.push([{ id: 9, name: "Builders", userId: 7 }], []);
|
||||
await expect(
|
||||
@@ -246,6 +301,70 @@ describe("People production workflow adapter", () => {
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("preserves BIGINT identifiers as canonical strings across application, team, IP, and wordfilter", async () => {
|
||||
const id = "9007199254740993";
|
||||
const databaseId = 9007199254740993n;
|
||||
|
||||
mocks.selectQueue.push([
|
||||
{ id: databaseId, userId: 7, rankId: 4, content: "Ready" },
|
||||
]);
|
||||
const application = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"application.decide",
|
||||
{ applicationId: id, decision: "dismiss" },
|
||||
);
|
||||
expect(application).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { id }, after: null },
|
||||
});
|
||||
|
||||
mocks.selectQueue.push([
|
||||
{
|
||||
id: databaseId,
|
||||
rankName: "MOD",
|
||||
badge: null,
|
||||
jobDescription: null,
|
||||
hiddenRank: false,
|
||||
},
|
||||
]);
|
||||
const team = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"team.change",
|
||||
{ action: "delete", teamId: id },
|
||||
);
|
||||
expect(team).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { id }, after: null },
|
||||
});
|
||||
|
||||
mocks.selectQueue.push([
|
||||
{ id: databaseId, ipAddress: "198.51.100.3", asn: null },
|
||||
]);
|
||||
const ip = await peopleMutationService.execute(invocation, "ip.action", {
|
||||
action: "delete-blacklist",
|
||||
id,
|
||||
});
|
||||
expect(ip).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { id }, after: null },
|
||||
});
|
||||
|
||||
mocks.selectQueue.push([{ id: databaseId, word: "spam" }]);
|
||||
const wordfilter = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"word-filter.update",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
expect(wordfilter).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { id }, after: null },
|
||||
});
|
||||
|
||||
for (const result of [application, team, ip, wordfilter]) {
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
expect(JSON.stringify(result)).not.toContain("9007199254740992");
|
||||
}
|
||||
});
|
||||
it("keeps VPN secrets out of audit while reloading settings and logging legacy activity", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
@@ -283,4 +402,420 @@ describe("People production workflow adapter", () => {
|
||||
expect(mocks.reloadWordFilter).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rcon.updateWordFilter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns correlated partial completion when committed ban sync reports false", async () => {
|
||||
mocks.selectQueue.push(
|
||||
[
|
||||
{
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
rank: 2,
|
||||
motto: "",
|
||||
credits: 0,
|
||||
pixels: 0,
|
||||
online: "1",
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 90,
|
||||
banExpire: Math.floor(Date.now() / 1000) + 24 * 3600,
|
||||
banReason: "Repeated abuse",
|
||||
type: "account",
|
||||
},
|
||||
],
|
||||
);
|
||||
mocks.rcon.disconnectUser.mockResolvedValue(false);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "committed-ban-partial" },
|
||||
"user.ban",
|
||||
{
|
||||
userId: 7,
|
||||
reason: "Repeated abuse",
|
||||
duration: 24,
|
||||
type: "account",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
correlationId: "committed-ban-partial",
|
||||
});
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => ({
|
||||
outcome: (call[0] as AuditEntry).outcome,
|
||||
correlationId: (call[0] as AuditEntry).correlationId,
|
||||
})),
|
||||
).toEqual([
|
||||
{ outcome: "intent", correlationId: "committed-ban-partial" },
|
||||
{ outcome: "partial", correlationId: "committed-ban-partial" },
|
||||
]);
|
||||
});
|
||||
it("executes update, unban, and reset-password with transactional audit and one-time output", async () => {
|
||||
mocks.selectQueue.push([target()]);
|
||||
const updated = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Updated" } },
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
ok: true,
|
||||
data: { after: { motto: "Updated" } },
|
||||
});
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith({ motto: "Updated" });
|
||||
expect(mocks.invalidateLoginCache).toHaveBeenCalledWith("Alice");
|
||||
|
||||
mocks.selectQueue.push(
|
||||
[target()],
|
||||
[
|
||||
{
|
||||
id: 22,
|
||||
banExpire: 0,
|
||||
banReason: "Permanent active ban",
|
||||
type: "account",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const unbanned = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.unban",
|
||||
{ userId: 7 },
|
||||
);
|
||||
expect(unbanned).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { banned: true, banExpire: 0 },
|
||||
after: { banned: false },
|
||||
},
|
||||
});
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
expect(mocks.selectQueue).toHaveLength(0);
|
||||
|
||||
mocks.selectQueue.push([target()]);
|
||||
const reset = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.reset-password",
|
||||
{ userId: 7, reason: "Owner verified identity" },
|
||||
);
|
||||
expect(reset).toMatchObject({
|
||||
ok: true,
|
||||
data: { output: { newPassword: expect.any(String) } },
|
||||
});
|
||||
if (!reset.ok) throw new Error("Expected successful reset");
|
||||
const newPassword = String(reset.data.output?.newPassword);
|
||||
expect(newPassword).toHaveLength(16);
|
||||
expect(mocks.hashPassword).toHaveBeenCalledWith(newPassword);
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith({
|
||||
password: "hashed-password",
|
||||
});
|
||||
const auditPayload = JSON.stringify(
|
||||
mocks.audit.mock.calls.map((call) => call[0] as AuditEntry),
|
||||
);
|
||||
expect(auditPayload).not.toContain(newPassword);
|
||||
expect(auditPayload).not.toContain("hashed-password");
|
||||
});
|
||||
|
||||
it("executes disconnect, mute, unmute, and send-currency through audited RCON", async () => {
|
||||
mocks.selectQueue.push([target()]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "user.disconnect", {
|
||||
userId: 7,
|
||||
reason: "Stuck session",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(mocks.rcon.disconnectUser).toHaveBeenCalledWith(7);
|
||||
|
||||
mocks.selectQueue.push([target()]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "user.mute", {
|
||||
userId: 7,
|
||||
duration: 30,
|
||||
reason: "Chat abuse",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(mocks.rcon.muteUser).toHaveBeenCalledWith(7, 30);
|
||||
|
||||
mocks.selectQueue.push([target()]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "user.unmute", {
|
||||
userId: 7,
|
||||
reason: "Appeal accepted",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(mocks.rcon.unmuteUser).toHaveBeenCalledWith(7);
|
||||
|
||||
mocks.selectQueue.push([target()]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "user.send-currency", {
|
||||
userId: 7,
|
||||
amount: 25,
|
||||
reason: "Event award",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(mocks.rcon.giveCredits).toHaveBeenCalledWith(7, 25);
|
||||
|
||||
const outcomes = mocks.audit.mock.calls.map(
|
||||
(call) => (call[0] as AuditEntry).outcome,
|
||||
);
|
||||
expect(outcomes).toEqual([
|
||||
"intent",
|
||||
"success",
|
||||
"intent",
|
||||
"success",
|
||||
"intent",
|
||||
"success",
|
||||
"intent",
|
||||
"success",
|
||||
]);
|
||||
});
|
||||
|
||||
it("executes bulk ban, currency, and badge with database, RCON, and legacy activity outcomes", async () => {
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-ban", {
|
||||
userIds: [7, 8],
|
||||
duration: 3600,
|
||||
reason: "Coordinated abuse",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
data: { after: { completed: 2, total: 2 } },
|
||||
});
|
||||
expect(mocks.insertValues).toHaveBeenCalledTimes(2);
|
||||
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-currency", {
|
||||
userIds: [7],
|
||||
type: "credits",
|
||||
amount: 50,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
data: { after: { completed: 1, total: 1 } },
|
||||
});
|
||||
expect(mocks.rcon.giveCredits).toHaveBeenCalledWith(7, 50);
|
||||
|
||||
mocks.selectQueue.push([], [{ maxSlot: 2 }]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-badge", {
|
||||
userIds: [7],
|
||||
badgeCode: "ADM",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
data: { after: { completed: 1, total: 1 } },
|
||||
});
|
||||
expect(mocks.rcon.giveBadge).toHaveBeenCalledWith(7, "ADM");
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "bulk_ban" }),
|
||||
);
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "bulk_give_currency" }),
|
||||
);
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "bulk_give_badge" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports bulk currency as partial after database commit when RCON reports false", async () => {
|
||||
mocks.rcon.giveCredits.mockResolvedValue(false);
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "bulk-currency-partial", legacy: false },
|
||||
"users.bulk-currency",
|
||||
{ userIds: [7], type: "credits", amount: 50 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: {
|
||||
after: {
|
||||
completed: 0,
|
||||
total: 1,
|
||||
failedIds: [{ userId: 7, reason: "Database error" }],
|
||||
},
|
||||
},
|
||||
correlationId: "bulk-currency-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
});
|
||||
it("returns typed partial after committed update or reset when notification fails", async () => {
|
||||
mocks.notify.mockRejectedValue(new Error("notification unavailable"));
|
||||
mocks.selectQueue.push([target()]);
|
||||
const updated = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "update-notify-partial", legacy: false },
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Updated" } },
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
correlationId: "update-notify-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.hashPassword.mockResolvedValue("hashed-password");
|
||||
mocks.notify.mockRejectedValue(new Error("notification unavailable"));
|
||||
mocks.selectQueue.push([target()]);
|
||||
const reset = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "reset-notify-partial", legacy: false },
|
||||
"user.reset-password",
|
||||
{ userId: 7, reason: "Verified owner" },
|
||||
);
|
||||
expect(reset).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: { output: { newPassword: expect.any(String) } },
|
||||
correlationId: "reset-notify-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
});
|
||||
|
||||
it("preserves legacy throw and false semantics while commands retain partial truth", async () => {
|
||||
mocks.selectQueue.push(
|
||||
[target()],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 91,
|
||||
banExpire: Math.floor(Date.now() / 1000) + 3600,
|
||||
banReason: "Repeated abuse",
|
||||
type: "account",
|
||||
},
|
||||
],
|
||||
);
|
||||
mocks.rcon.disconnectUser.mockRejectedValue(new Error("RCON unavailable"));
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "user.ban", {
|
||||
userId: 7,
|
||||
reason: "Repeated abuse",
|
||||
duration: 1,
|
||||
type: "account",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.rcon.giveCredits.mockResolvedValue(false);
|
||||
const legacyBulk = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"users.bulk-currency",
|
||||
{ userIds: [7], type: "credits", amount: 50 },
|
||||
);
|
||||
expect(legacyBulk).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
after: { completed: 1, total: 1, failedIds: [] },
|
||||
},
|
||||
});
|
||||
expect(legacyBulk).not.toHaveProperty("completion");
|
||||
});
|
||||
it("reports trade, VPN, and wordfilter post-commit sync failures as typed partial", async () => {
|
||||
mocks.selectQueue.push([target()], [], []);
|
||||
mocks.rcon.setTradeLock.mockResolvedValue(false);
|
||||
const trade = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "trade-sync-partial", legacy: false },
|
||||
"user.trade-lock",
|
||||
{ userId: 7, untilUnix: 200 },
|
||||
);
|
||||
expect(trade).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
correlationId: "trade-sync-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.reloadSettings.mockRejectedValue(new Error("cache unavailable"));
|
||||
const vpn = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "vpn-cache-partial", legacy: false },
|
||||
"vpn.configure",
|
||||
{
|
||||
enabled: true,
|
||||
provider: "proxycheck",
|
||||
apiKey: "new-secret",
|
||||
blockMessage: "No VPN",
|
||||
},
|
||||
);
|
||||
expect(vpn).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
correlationId: "vpn-cache-partial",
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockImplementation(async (entry: AuditEntry) => {
|
||||
if (entry.outcome === "partial") throw new Error("audit unavailable");
|
||||
});
|
||||
mocks.reloadWordFilter.mockResolvedValue(undefined);
|
||||
mocks.rcon.updateWordFilter.mockResolvedValue(false);
|
||||
mocks.selectQueue.push([]);
|
||||
const wordfilter = await peopleMutationService.execute(
|
||||
{
|
||||
...invocation,
|
||||
correlationId: "wordfilter-sync-partial",
|
||||
legacy: false,
|
||||
},
|
||||
"word-filter.update",
|
||||
{ action: "delete", id: "99" },
|
||||
);
|
||||
expect(wordfilter).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "unavailable",
|
||||
},
|
||||
correlationId: "wordfilter-sync-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
});
|
||||
});
|
||||
@@ -99,14 +99,14 @@ describe("People production mutation boundary", () => {
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { id: 7, username: "Alice", online: true },
|
||||
after: { id: 7, alertDelivered: true },
|
||||
before: null,
|
||||
after: { userId: 7, alertDelivered: true },
|
||||
},
|
||||
});
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "people.user.alert",
|
||||
before: expect.objectContaining({ id: 7 }),
|
||||
before: undefined,
|
||||
after: expect.objectContaining({ alertDelivered: true }),
|
||||
correlationId: "production-alert",
|
||||
outcome: "success",
|
||||
|
||||
+28
-3
@@ -10,6 +10,7 @@ const {
|
||||
selectLimit,
|
||||
transaction,
|
||||
txSelectLimit,
|
||||
txSelectOrderBy,
|
||||
} = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
@@ -18,6 +19,7 @@ const {
|
||||
selectLimit: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
txSelectLimit: vi.fn(),
|
||||
txSelectOrderBy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
@@ -30,7 +32,7 @@ vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: txSelectLimit })),
|
||||
where: vi.fn(() => ({ orderBy: txSelectOrderBy })),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
@@ -87,7 +89,24 @@ beforeEach(() => {
|
||||
online: "1",
|
||||
},
|
||||
]);
|
||||
txSelectLimit.mockResolvedValue([]);
|
||||
txSelectLimit
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 88,
|
||||
banExpire: 0,
|
||||
banReason: "Permanent active ban",
|
||||
type: "account",
|
||||
},
|
||||
])
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: 89,
|
||||
banExpire: Math.floor(Date.now() / 1000) + 24 * 3600,
|
||||
banReason: "Repeated harassment",
|
||||
type: "account",
|
||||
},
|
||||
]);
|
||||
txSelectOrderBy.mockReturnValue({ limit: txSelectLimit });
|
||||
insertValues.mockResolvedValue([{}]);
|
||||
disconnectUser.mockResolvedValue(true);
|
||||
audit.mockResolvedValue(undefined);
|
||||
@@ -112,7 +131,12 @@ describe("People production reason audit", () => {
|
||||
expect.objectContaining({
|
||||
action: "people.user.ban",
|
||||
outcome: "intent",
|
||||
before: expect.objectContaining({ id: 7, banned: false }),
|
||||
before: expect.objectContaining({
|
||||
id: 7,
|
||||
banned: true,
|
||||
banExpire: 0,
|
||||
reason: "Permanent active ban",
|
||||
}),
|
||||
after: expect.objectContaining({
|
||||
id: 7,
|
||||
banned: true,
|
||||
@@ -121,6 +145,7 @@ describe("People production reason audit", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(txSelectOrderBy).toHaveBeenCalledTimes(2);
|
||||
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
|
||||
expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail");
|
||||
});
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ describe("People transactional database audit", () => {
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outcome: "success",
|
||||
before: expect.objectContaining({ id: 9 }),
|
||||
before: expect.objectContaining({ id: "9" }),
|
||||
after: undefined,
|
||||
}),
|
||||
expect.objectContaining({ insert: expect.any(Function) }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, max, or, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingPartialCompletion,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
@@ -72,6 +73,7 @@ export interface PeopleMutationSnapshot {
|
||||
readonly before: Readonly<Record<string, unknown>> | null;
|
||||
readonly after: Readonly<Record<string, unknown>> | null;
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
readonly completion?: HousekeepingPartialCompletion;
|
||||
}
|
||||
|
||||
export interface PeopleMutationInvocation {
|
||||
@@ -164,16 +166,22 @@ export function createPeopleMutationService(
|
||||
legacy: invocation.legacy === true,
|
||||
};
|
||||
try {
|
||||
return ok(
|
||||
await adapter.execute(operation, input, context),
|
||||
invocation.correlationId,
|
||||
);
|
||||
const snapshot = await adapter.execute(operation, input, context);
|
||||
return ok(snapshot, invocation.correlationId, snapshot.completion);
|
||||
} catch (error) {
|
||||
if (error instanceof AuditOutcomePersistenceError) {
|
||||
if (invocation.legacy) {
|
||||
return ok(error.operationResult, invocation.correlationId);
|
||||
}
|
||||
throw error;
|
||||
const operationSnapshot =
|
||||
error.operationResult as PeopleMutationSnapshot;
|
||||
const completion: HousekeepingPartialCompletion = {
|
||||
status: "partial",
|
||||
external: operationSnapshot.completion?.external ?? "completed",
|
||||
audit: "unavailable",
|
||||
};
|
||||
const snapshot = {
|
||||
...operationSnapshot,
|
||||
completion,
|
||||
};
|
||||
return ok(snapshot, invocation.correlationId, completion);
|
||||
}
|
||||
if (error instanceof PeopleMutationFailure) {
|
||||
return fail(
|
||||
@@ -221,6 +229,43 @@ function positiveInteger(value: unknown): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
|
||||
const MAX_AUDIT_TARGET_ID = 2_147_483_647n;
|
||||
|
||||
function positiveBigIntIdentifier(value: unknown): bigint {
|
||||
let parsed: bigint;
|
||||
if (typeof value === "bigint") {
|
||||
parsed = value;
|
||||
} else if (typeof value === "string" && /^[1-9]\d{0,19}$/u.test(value)) {
|
||||
parsed = BigInt(value);
|
||||
} else if (
|
||||
typeof value === "number" &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value > 0
|
||||
) {
|
||||
parsed = BigInt(value);
|
||||
} else {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function serializeBigIntIdentifier(value: unknown): string {
|
||||
return positiveBigIntIdentifier(value).toString();
|
||||
}
|
||||
|
||||
function auditTargetId(value: bigint): number | undefined {
|
||||
return value <= MAX_AUDIT_TARGET_ID ? Number(value) : undefined;
|
||||
}
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
@@ -356,10 +401,12 @@ async function finalizeExternalWithAudit(
|
||||
targetId: number | undefined,
|
||||
snapshot: PeopleMutationSnapshot,
|
||||
execute: () => Promise<void>,
|
||||
mutationCommitted = true,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
try {
|
||||
await execute();
|
||||
} catch (error) {
|
||||
let audit: HousekeepingPartialCompletion["audit"] = "persisted";
|
||||
try {
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
@@ -368,13 +415,23 @@ async function finalizeExternalWithAudit(
|
||||
target,
|
||||
targetId,
|
||||
snapshot,
|
||||
"failure",
|
||||
mutationCommitted ? "partial" : "failure",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Preserve the external failure as the authoritative result.
|
||||
audit = "unavailable";
|
||||
}
|
||||
throw error;
|
||||
if (
|
||||
!mutationCommitted ||
|
||||
(context.legacy && !(error instanceof PeopleMutationFailure))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return withPartialCompletion(snapshot, {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await logAudit(
|
||||
@@ -387,7 +444,9 @@ async function finalizeExternalWithAudit(
|
||||
"success",
|
||||
),
|
||||
);
|
||||
} catch (auditOutcomeError) {
|
||||
return snapshot;
|
||||
} catch {
|
||||
let audit: HousekeepingPartialCompletion["audit"] = "persisted";
|
||||
try {
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
@@ -400,13 +459,22 @@ async function finalizeExternalWithAudit(
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// The typed completed-operation error remains primary.
|
||||
audit = "unavailable";
|
||||
}
|
||||
throw new AuditOutcomePersistenceError(snapshot, auditOutcomeError);
|
||||
return withPartialCompletion(snapshot, {
|
||||
status: "partial",
|
||||
external: "completed",
|
||||
audit,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function withPartialCompletion(
|
||||
snapshot: PeopleMutationSnapshot,
|
||||
completion: HousekeepingPartialCompletion,
|
||||
): PeopleMutationSnapshot {
|
||||
return { ...snapshot, completion };
|
||||
}
|
||||
async function runExternalWithAudit(
|
||||
context: PeopleMutationContext,
|
||||
operation: PeopleMutationOperation,
|
||||
@@ -432,6 +500,7 @@ async function runExternalWithAudit(
|
||||
targetId,
|
||||
snapshot,
|
||||
execute,
|
||||
false,
|
||||
);
|
||||
}
|
||||
async function executeUserMutation(
|
||||
@@ -441,8 +510,24 @@ async function executeUserMutation(
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const userId = positiveInteger(data.userId);
|
||||
const guardHierarchy =
|
||||
operation !== "user.alert" && operation !== "user.trade-lock";
|
||||
|
||||
if (operation === "user.alert") {
|
||||
const message = normalizedText(data.message, 500);
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before: null,
|
||||
after: { userId, alertDelivered: true },
|
||||
};
|
||||
return runExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"User",
|
||||
userId,
|
||||
snapshot,
|
||||
async () => requireRcon(await rcon.alertUser(userId, message)),
|
||||
);
|
||||
}
|
||||
|
||||
const guardHierarchy = operation !== "user.trade-lock";
|
||||
const target = await loadTarget(userId, context, guardHierarchy);
|
||||
const before = targetSnapshot(target);
|
||||
|
||||
@@ -494,19 +579,28 @@ async function executeUserMutation(
|
||||
"User",
|
||||
userId,
|
||||
snapshot,
|
||||
"success",
|
||||
"intent",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
invalidateLoginCache(target.username);
|
||||
await notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
targetId: userId,
|
||||
});
|
||||
return snapshot;
|
||||
return finalizeExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"User",
|
||||
userId,
|
||||
snapshot,
|
||||
async () => {
|
||||
invalidateLoginCache(target.username);
|
||||
const notification = notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
targetId: userId,
|
||||
});
|
||||
if (!context.legacy) await notification;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (operation === "user.ban") {
|
||||
@@ -532,7 +626,13 @@ async function executeUserMutation(
|
||||
type: Ban.type,
|
||||
})
|
||||
.from(Ban)
|
||||
.where(eq(Ban.userId, userId))
|
||||
.where(
|
||||
and(
|
||||
eq(Ban.userId, userId),
|
||||
or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(Ban.timestamp), desc(Ban.id))
|
||||
.limit(1);
|
||||
await tx.insert(Ban).values({
|
||||
userId,
|
||||
@@ -544,6 +644,22 @@ async function executeUserMutation(
|
||||
ip: normalizedText(data.ip, 255, false),
|
||||
machineId: "",
|
||||
});
|
||||
const [observedAfterBan] = await tx
|
||||
.select({
|
||||
id: Ban.id,
|
||||
banExpire: Ban.banExpire,
|
||||
banReason: Ban.banReason,
|
||||
type: Ban.type,
|
||||
})
|
||||
.from(Ban)
|
||||
.where(
|
||||
and(
|
||||
eq(Ban.userId, userId),
|
||||
or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(Ban.timestamp), desc(Ban.id))
|
||||
.limit(1);
|
||||
snapshot = {
|
||||
before: {
|
||||
...before,
|
||||
@@ -556,7 +672,17 @@ async function executeUserMutation(
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
after: { ...before, banned: true, banExpire, type, reason },
|
||||
after: {
|
||||
...before,
|
||||
banned: observedAfterBan !== undefined,
|
||||
...(observedAfterBan
|
||||
? {
|
||||
banExpire: observedAfterBan.banExpire,
|
||||
type: observedAfterBan.type,
|
||||
reason: observedAfterBan.banReason,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
@@ -577,7 +703,7 @@ async function executeUserMutation(
|
||||
userId,
|
||||
snapshot,
|
||||
async () => {
|
||||
await rcon.disconnectUser(userId);
|
||||
await requireRcon(await rcon.disconnectUser(userId));
|
||||
await notify({
|
||||
action: "ban",
|
||||
actor: context.capability.actor.username,
|
||||
@@ -589,6 +715,7 @@ async function executeUserMutation(
|
||||
}
|
||||
|
||||
if (operation === "user.unban") {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const snapshot = await db.transaction(async (tx) => {
|
||||
const [activeBan] = await tx
|
||||
.select({
|
||||
@@ -598,9 +725,31 @@ async function executeUserMutation(
|
||||
type: Ban.type,
|
||||
})
|
||||
.from(Ban)
|
||||
.where(eq(Ban.userId, userId))
|
||||
.where(
|
||||
and(
|
||||
eq(Ban.userId, userId),
|
||||
or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(Ban.timestamp), desc(Ban.id))
|
||||
.limit(1);
|
||||
await tx.delete(Ban).where(eq(Ban.userId, userId));
|
||||
const [observedAfterBan] = await tx
|
||||
.select({
|
||||
id: Ban.id,
|
||||
banExpire: Ban.banExpire,
|
||||
banReason: Ban.banReason,
|
||||
type: Ban.type,
|
||||
})
|
||||
.from(Ban)
|
||||
.where(
|
||||
and(
|
||||
eq(Ban.userId, userId),
|
||||
or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(Ban.timestamp), desc(Ban.id))
|
||||
.limit(1);
|
||||
const value: PeopleMutationSnapshot = {
|
||||
before: {
|
||||
...before,
|
||||
@@ -613,7 +762,17 @@ async function executeUserMutation(
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
after: { ...before, banned: false },
|
||||
after: {
|
||||
...before,
|
||||
banned: observedAfterBan !== undefined,
|
||||
...(observedAfterBan
|
||||
? {
|
||||
banExpire: observedAfterBan.banExpire,
|
||||
type: observedAfterBan.type,
|
||||
reason: observedAfterBan.banReason,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
@@ -636,19 +795,6 @@ async function executeUserMutation(
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (operation === "user.alert") {
|
||||
const message = normalizedText(data.message, 500);
|
||||
const snapshot = { before, after: { ...before, alertDelivered: true } };
|
||||
return runExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"User",
|
||||
userId,
|
||||
snapshot,
|
||||
async () => requireRcon(await rcon.alertUser(userId, message)),
|
||||
);
|
||||
}
|
||||
|
||||
if (operation === "user.disconnect") {
|
||||
const snapshot = { before, after: { ...before, online: false } };
|
||||
return runExternalWithAudit(
|
||||
@@ -718,19 +864,28 @@ async function executeUserMutation(
|
||||
"User",
|
||||
userId,
|
||||
snapshot,
|
||||
"success",
|
||||
"intent",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
invalidateLoginCache(target.username);
|
||||
await notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
return snapshot;
|
||||
return finalizeExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"User",
|
||||
userId,
|
||||
snapshot,
|
||||
async () => {
|
||||
invalidateLoginCache(target.username);
|
||||
const notification = notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
if (!context.legacy) await notification;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (operation === "user.send-currency") {
|
||||
@@ -801,8 +956,10 @@ async function executeUserMutation(
|
||||
after: {
|
||||
...before,
|
||||
tradeLockedUntil: untilUnix,
|
||||
canTrade: locked ? "0" : "1",
|
||||
tradelockAmount: (settings?.tradelockAmount ?? 0) + (locked ? 1 : 0),
|
||||
canTrade: settings ? (locked ? "0" : "1") : null,
|
||||
tradelockAmount: settings
|
||||
? settings.tradelockAmount + (locked ? 1 : 0)
|
||||
: null,
|
||||
},
|
||||
output: { userId, untilUnix },
|
||||
};
|
||||
@@ -825,15 +982,17 @@ async function executeUserMutation(
|
||||
userId,
|
||||
snapshot,
|
||||
async () => {
|
||||
await rcon.setTradeLock(userId, locked);
|
||||
await rcon.alertUser(
|
||||
userId,
|
||||
locked
|
||||
? "Trading has been disabled by staff."
|
||||
: "Trading has been re-enabled by staff.",
|
||||
await requireRcon(await rcon.setTradeLock(userId, locked));
|
||||
await requireRcon(
|
||||
await rcon.alertUser(
|
||||
userId,
|
||||
locked
|
||||
? "Trading has been disabled by staff."
|
||||
: "Trading has been re-enabled by staff.",
|
||||
),
|
||||
);
|
||||
if (target.online === "1") {
|
||||
await rcon.disconnectUser(userId, target.username);
|
||||
await requireRcon(await rcon.disconnectUser(userId, target.username));
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
@@ -856,6 +1015,7 @@ async function executeBulkMutation(
|
||||
const ids = userIds(data.userIds, context.legacy);
|
||||
const failures: Array<{ userId: number; reason: string }> = [];
|
||||
let completed = 0;
|
||||
let externalFailed = false;
|
||||
let mutationReason: string | undefined;
|
||||
|
||||
if (operation === "users.bulk-unban") {
|
||||
@@ -966,13 +1126,16 @@ async function executeBulkMutation(
|
||||
| "pixels"
|
||||
| "points";
|
||||
for (const userId of ids) {
|
||||
let databaseCommitted = false;
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.where(eq(User.id, userId));
|
||||
await rcon.giveCredits(userId, amount);
|
||||
databaseCommitted = true;
|
||||
const delivered = await rcon.giveCredits(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
await db
|
||||
@@ -981,11 +1144,18 @@ async function executeBulkMutation(
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
if (type === "pixels") await rcon.giveDuckets(userId, amount);
|
||||
else await rcon.givePointsGotw(userId, amount);
|
||||
databaseCommitted = true;
|
||||
if (type === "pixels") {
|
||||
const delivered = await rcon.giveDuckets(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
} else {
|
||||
const delivered = await rcon.givePointsGotw(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
}
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
if (databaseCommitted) externalFailed = true;
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
@@ -998,6 +1168,7 @@ async function executeBulkMutation(
|
||||
} else {
|
||||
const badgeCode = normalizedText(data.badgeCode, 20);
|
||||
for (const userId of ids) {
|
||||
let databaseCommitted = false;
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
@@ -1019,10 +1190,13 @@ async function executeBulkMutation(
|
||||
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
||||
badgeCode,
|
||||
});
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
databaseCommitted = true;
|
||||
const delivered = await rcon.giveBadge(userId, badgeCode);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
if (databaseCommitted) externalFailed = true;
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
@@ -1034,6 +1208,14 @@ async function executeBulkMutation(
|
||||
});
|
||||
}
|
||||
|
||||
const completion: HousekeepingPartialCompletion | undefined =
|
||||
failures.length > 0
|
||||
? {
|
||||
status: "partial",
|
||||
external: externalFailed ? "failed" : "not-required",
|
||||
audit: "persisted",
|
||||
}
|
||||
: undefined;
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before: { userIds: ids },
|
||||
after: {
|
||||
@@ -1042,6 +1224,7 @@ async function executeBulkMutation(
|
||||
failedIds: failures,
|
||||
...(mutationReason ? { reason: mutationReason } : {}),
|
||||
},
|
||||
...(completion === undefined ? {} : { completion }),
|
||||
};
|
||||
try {
|
||||
await logAudit(
|
||||
@@ -1137,7 +1320,7 @@ async function executeApplicationDecision(
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const applicationId = positiveInteger(data.applicationId);
|
||||
const applicationId = positiveBigIntIdentifier(data.applicationId);
|
||||
if (data.decision !== "dismiss") {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
@@ -1153,7 +1336,7 @@ async function executeApplicationDecision(
|
||||
content: WebsiteStaffApplications.content,
|
||||
})
|
||||
.from(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, BigInt(applicationId)))
|
||||
.where(eq(WebsiteStaffApplications.id, applicationId))
|
||||
.limit(1);
|
||||
if (!application) {
|
||||
throw new PeopleMutationFailure(
|
||||
@@ -1163,10 +1346,10 @@ async function executeApplicationDecision(
|
||||
}
|
||||
await tx
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, BigInt(applicationId)));
|
||||
.where(eq(WebsiteStaffApplications.id, applicationId));
|
||||
const snapshot = {
|
||||
before: {
|
||||
id: Number(application.id),
|
||||
id: serializeBigIntIdentifier(application.id),
|
||||
userId: application.userId,
|
||||
rankId: application.rankId,
|
||||
content: application.content,
|
||||
@@ -1178,7 +1361,7 @@ async function executeApplicationDecision(
|
||||
context,
|
||||
"application.decide",
|
||||
"StaffApplication",
|
||||
applicationId,
|
||||
auditTargetId(applicationId),
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
@@ -1194,7 +1377,7 @@ async function executeTeamChange(
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
if (data.action === "delete") {
|
||||
const teamId = positiveInteger(data.teamId);
|
||||
const teamId = positiveBigIntIdentifier(data.teamId);
|
||||
return db.transaction(async (tx) => {
|
||||
const [team] = await tx
|
||||
.select({
|
||||
@@ -1205,7 +1388,7 @@ async function executeTeamChange(
|
||||
hiddenRank: WebsiteTeams.hiddenRank,
|
||||
})
|
||||
.from(WebsiteTeams)
|
||||
.where(eq(WebsiteTeams.id, BigInt(teamId)))
|
||||
.where(eq(WebsiteTeams.id, teamId))
|
||||
.limit(1);
|
||||
if (!team) {
|
||||
throw new PeopleMutationFailure(
|
||||
@@ -1213,9 +1396,9 @@ async function executeTeamChange(
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await tx.delete(WebsiteTeams).where(eq(WebsiteTeams.id, BigInt(teamId)));
|
||||
await tx.delete(WebsiteTeams).where(eq(WebsiteTeams.id, teamId));
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before: { ...team, id: Number(team.id) },
|
||||
before: { ...team, id: serializeBigIntIdentifier(team.id) },
|
||||
after: null,
|
||||
};
|
||||
await logAudit(
|
||||
@@ -1223,7 +1406,7 @@ async function executeTeamChange(
|
||||
context,
|
||||
"team.change",
|
||||
"Team",
|
||||
teamId,
|
||||
auditTargetId(teamId),
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
@@ -1249,11 +1432,11 @@ async function executeTeamChange(
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const teamId = Number(result.insertId);
|
||||
const teamId = positiveBigIntIdentifier(result.insertId);
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before: null,
|
||||
after: {
|
||||
teamId,
|
||||
teamId: teamId.toString(),
|
||||
rankName,
|
||||
badge: badge || null,
|
||||
jobDescription: jobDescription || null,
|
||||
@@ -1266,7 +1449,7 @@ async function executeTeamChange(
|
||||
context,
|
||||
"team.change",
|
||||
"Team",
|
||||
teamId,
|
||||
auditTargetId(teamId),
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
@@ -1309,17 +1492,17 @@ async function executeIpAction(
|
||||
: await tx
|
||||
.insert(WebsiteIpWhitelist)
|
||||
.values({ ipAddress, asn, whitelistAsn: asn !== null });
|
||||
const id = Number(result.insertId);
|
||||
const id = positiveBigIntIdentifier(result.insertId);
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before: null,
|
||||
after: { id, action, ipAddress, asn },
|
||||
after: { id: id.toString(), action, ipAddress, asn },
|
||||
};
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
"ip.action",
|
||||
"IpRule",
|
||||
id,
|
||||
auditTargetId(id),
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
@@ -1328,7 +1511,7 @@ async function executeIpAction(
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
const id = positiveBigIntIdentifier(data.id);
|
||||
return db.transaction(async (tx) => {
|
||||
const [existing] = blacklist
|
||||
? await tx
|
||||
@@ -1338,7 +1521,7 @@ async function executeIpAction(
|
||||
asn: WebsiteIpBlacklist.asn,
|
||||
})
|
||||
.from(WebsiteIpBlacklist)
|
||||
.where(eq(WebsiteIpBlacklist.id, BigInt(id)))
|
||||
.where(eq(WebsiteIpBlacklist.id, id))
|
||||
.limit(1)
|
||||
: await tx
|
||||
.select({
|
||||
@@ -1347,21 +1530,17 @@ async function executeIpAction(
|
||||
asn: WebsiteIpWhitelist.asn,
|
||||
})
|
||||
.from(WebsiteIpWhitelist)
|
||||
.where(eq(WebsiteIpWhitelist.id, BigInt(id)))
|
||||
.where(eq(WebsiteIpWhitelist.id, id))
|
||||
.limit(1);
|
||||
if (blacklist) {
|
||||
await tx
|
||||
.delete(WebsiteIpBlacklist)
|
||||
.where(eq(WebsiteIpBlacklist.id, BigInt(id)));
|
||||
await tx.delete(WebsiteIpBlacklist).where(eq(WebsiteIpBlacklist.id, id));
|
||||
} else {
|
||||
await tx
|
||||
.delete(WebsiteIpWhitelist)
|
||||
.where(eq(WebsiteIpWhitelist.id, BigInt(id)));
|
||||
await tx.delete(WebsiteIpWhitelist).where(eq(WebsiteIpWhitelist.id, id));
|
||||
}
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before: existing
|
||||
? {
|
||||
id: Number(existing.id),
|
||||
id: serializeBigIntIdentifier(existing.id),
|
||||
action,
|
||||
ipAddress: existing.ipAddress,
|
||||
asn: existing.asn,
|
||||
@@ -1374,7 +1553,7 @@ async function executeIpAction(
|
||||
context,
|
||||
"ip.action",
|
||||
"IpRule",
|
||||
id,
|
||||
auditTargetId(id),
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
@@ -1472,20 +1651,20 @@ async function executeWordFilterUpdate(
|
||||
const data = record(input);
|
||||
if (data.action === "add") {
|
||||
const word = normalizedText(data.word, 255);
|
||||
let id = 0;
|
||||
let id = 0n;
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [result] = (await tx
|
||||
.insert(WebsiteWordfilter)
|
||||
.values({ word })) as unknown as [ResultSetHeader];
|
||||
id = Number(result.insertId);
|
||||
snapshot = { before: null, after: { id, word } };
|
||||
id = positiveBigIntIdentifier(result.insertId);
|
||||
snapshot = { before: null, after: { id: id.toString(), word } };
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
auditTargetId(id),
|
||||
snapshot,
|
||||
"intent",
|
||||
),
|
||||
@@ -1496,11 +1675,11 @@ async function executeWordFilterUpdate(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
auditTargetId(id),
|
||||
snapshot,
|
||||
async () => {
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
await requireRcon(await rcon.updateWordFilter());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1510,20 +1689,18 @@ async function executeWordFilterUpdate(
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
const id = positiveBigIntIdentifier(data.id);
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: WebsiteWordfilter.id, word: WebsiteWordfilter.word })
|
||||
.from(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(id)))
|
||||
.where(eq(WebsiteWordfilter.id, id))
|
||||
.limit(1);
|
||||
await tx
|
||||
.delete(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(id)));
|
||||
await tx.delete(WebsiteWordfilter).where(eq(WebsiteWordfilter.id, id));
|
||||
snapshot = {
|
||||
before: existing
|
||||
? { id: Number(existing.id), word: existing.word }
|
||||
? { id: serializeBigIntIdentifier(existing.id), word: existing.word }
|
||||
: null,
|
||||
after: null,
|
||||
};
|
||||
@@ -1532,7 +1709,7 @@ async function executeWordFilterUpdate(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
auditTargetId(id),
|
||||
snapshot,
|
||||
"intent",
|
||||
),
|
||||
@@ -1543,11 +1720,11 @@ async function executeWordFilterUpdate(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
auditTargetId(id),
|
||||
snapshot,
|
||||
async () => {
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
await requireRcon(await rcon.updateWordFilter());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user