fix(housekeeping): harden people account workflows

This commit is contained in:
Simo committed 2026-08-29 13:13:04 +02:00
1 parent e1b31ff773
commit 25b76437ff
41 files changed
+2657 -838

No files matched your search

+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -18,11 +18,7 @@ export async function dismissApplication(formData: FormData): Promise<void> {
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"application.decide",
{ applicationId, decision: "dismiss" },
);
+5 -7
View File
@@ -5,13 +5,11 @@ import { disbandGuild } from "./admin-guilds";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -16,11 +16,7 @@ export async function disbandGuild(formData: FormData): Promise<void> {
if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"guild.disband",
{ guildId },
);
+5 -7
View File
@@ -10,13 +10,11 @@ import {
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -37,11 +37,7 @@ async function run(
const id = "id" in input ? input.id : undefined;
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"ip.action",
input,
);
+5 -7
View File
@@ -5,13 +5,11 @@ import { createTeam, deleteTeam } from "./admin-teams";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+3 -11
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -20,11 +20,7 @@ export async function createTeam(formData: FormData): Promise<void> {
const rankName = text(formData, "rankName");
if (!rankName) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
{
action: "create",
@@ -44,11 +40,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
const teamId = Number(formData.get("id"));
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
{ action: "delete", teamId },
);
+5 -7
View File
@@ -6,13 +6,11 @@ import { saveVpn } from "./admin-vpn";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+2 -6
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -20,11 +20,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
.toLowerCase();
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"vpn.configure",
{
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
+3 -11
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -24,11 +24,7 @@ export async function addWord(input: {
.slice(0, 255);
if (!word) return actionError("Word is required");
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
{ action: "add", word },
);
@@ -44,11 +40,7 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id");
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
{ action: "delete", id },
);
+5 -7
View File
@@ -3,13 +3,11 @@ import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+6 -8
View File
@@ -10,13 +10,11 @@ import {
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -88,7 +86,7 @@ describe("legacy bulk user wrappers", () => {
data: { userId: 9, untilUnix: 1234 },
});
expect(execute).toHaveBeenLastCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }),
expect.objectContaining({ expectedActorId: 1 }),
"user.trade-lock",
{ userId: 9, untilUnix: 1234 },
);
+11 -7
View File
@@ -2,7 +2,7 @@
import { and, eq } from "drizzle-orm";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -27,11 +27,7 @@ async function executeLegacy(
input: unknown,
) {
return peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
@@ -259,6 +255,14 @@ export async function setTradeLock({
userId,
untilUnix: until,
});
if (!result.ok) return { ok: false, error: "Trade lock update failed" };
if (!result.ok) {
return {
ok: false,
error:
result.error.code === "NOT_FOUND"
? "User not found"
: "Trade lock update failed",
};
}
return { ok: true, data: { userId, untilUnix: until } };
}
+13 -14
View File
@@ -8,13 +8,11 @@ const { execute, staff } = vi.hoisted(() => ({
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -98,12 +96,13 @@ describe("legacy user safe-action wrappers", () => {
}>
)({ userId: 7 });
expect(
execute.mock.calls.map((call) => [call[0].permission, call[1]]),
).toEqual([
["admin.users.edit", "user.update"],
["admin.users.ban", "user.ban"],
["admin.users.reset_password", "user.reset-password"],
expect(execute.mock.calls.map((call) => call[1])).toEqual([
"user.update",
"user.ban",
"user.reset-password",
]);
expect(execute.mock.calls.map((call) => call[0].expectedActorId)).toEqual([
1, 1, 1,
]);
expect(reset.data.newPassword).toBe("temporary-password");
});
@@ -113,7 +112,7 @@ describe("legacy application and word-filter wrappers", () => {
it("keeps tolerant application dismissal and /admin revalidation", async () => {
await dismissApplication(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }),
expect.objectContaining({ expectedActorId: 1 }),
"application.decide",
{ applicationId: 9, decision: "dismiss" },
);
+5 -7
View File
@@ -7,13 +7,11 @@ const { execute, staff } = vi.hoisted(() => ({
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({
+11 -21
View File
@@ -3,7 +3,7 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
type PeopleMutationOperation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
@@ -116,16 +116,11 @@ const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
async function executeLegacy(
ctx: { session: { user: { id: number; username: string; rank: number } } },
permission: string,
operation: PeopleMutationOperation,
input: unknown,
) {
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
ctx.session.user,
permission,
createCorrelationId(),
),
createPeopleMutationInvocation(ctx.session.user, createCorrelationId()),
operation,
input,
);
@@ -148,7 +143,7 @@ export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id: userId, ...fields } = ctx.data;
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", {
await executeLegacy(ctx, "user.update", {
userId,
fields,
});
@@ -159,7 +154,7 @@ export const updateUser = adminAction(
export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data);
await executeLegacy(ctx, "user.ban", ctx.data);
return actionOk();
},
);
@@ -169,7 +164,7 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data);
await executeLegacy(ctx, "user.unban", ctx.data);
return actionOk();
},
);
@@ -177,12 +172,7 @@ export const unbanUser = adminAction(
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
async (ctx) => {
const snapshot = await executeLegacy(
ctx,
PERMS.USERS_RESET_PASSWORD,
"user.reset-password",
ctx.data,
);
const snapshot = await executeLegacy(ctx, "user.reset-password", ctx.data);
return actionOk({
newPassword: String(snapshot.output?.newPassword ?? ""),
});
@@ -192,7 +182,7 @@ export const resetPassword = adminAction(
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data);
await executeLegacy(ctx, "user.disconnect", ctx.data);
return actionOk();
},
);
@@ -203,7 +193,7 @@ const alertUserSchema = userIdSchema.extend({
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data);
await executeLegacy(ctx, "user.alert", ctx.data);
return actionOk();
},
);
@@ -214,7 +204,7 @@ const muteSchema = userIdSchema.extend({
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data);
await executeLegacy(ctx, "user.mute", ctx.data);
return actionOk();
},
);
@@ -222,7 +212,7 @@ export const muteUser = adminAction(
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data);
await executeLegacy(ctx, "user.unmute", ctx.data);
return actionOk();
},
);
@@ -233,7 +223,7 @@ const sendCreditsSchema = userIdSchema.extend({
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data);
await executeLegacy(ctx, "user.send-currency", ctx.data);
return actionOk();
},
);