fix(housekeeping): harden people account workflows
This commit is contained in:
1 parent
e1b31ff773
commit
25b76437ff
41 files changed
+2657
-838
No files matched your search
@@ -153,3 +153,56 @@ Exit 0
|
||||
The Node engine warning remains the approved non-blocker: the repository requests Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. All test and type gates exited successfully.
|
||||
|
||||
No database operation, deployment, push, or pull-request update was performed.
|
||||
|
||||
## Official review fix round 1
|
||||
|
||||
The official review reported 0 Critical and 5 Important findings. This round addresses the five findings without widening the Task 12 route catalog or changing legacy redirects, safe-action response shapes, or cutover behavior.
|
||||
|
||||
### RED evidence
|
||||
|
||||
```text
|
||||
Production server authority: auth resolver was called 0 times at the public service boundary (1 failing regression).
|
||||
Canonical external audit: 3 failing regressions for missing durable intent/outcome behavior.
|
||||
Transactional audit: expected one transaction and observed zero (1 failing regression).
|
||||
Legacy/production workflows: new production matrix initially exposed bulk truncation/deduplication, trade-lock hierarchy/state, missing StaffActivities, and missing word-filter refresh behavior.
|
||||
Primary workflows: page suite started at 7 passed / 2 failed (no executable command form and no bounded URL parser); preview contract started at 61 passed / 3 failed (searchParams/loading propagation).
|
||||
Import boundary after real forms: test:housekeeping reached 481 passed / 1 failed, then the focused boundary exposed one exact page-state -> People models edge (22 passed / 1 failed).
|
||||
```
|
||||
|
||||
### GREEN implementation
|
||||
|
||||
- Production People services now rehydrate `getHousekeepingCapabilityContext()` on every public mutation. Invocation data can carry correlation and an expected actor only; it cannot synthesize permissions. The production adapter stays private, while test factories inject an authority resolver.
|
||||
- Pure database mutations write their canonical before/after audit evidence in the same transaction. Mixed database/RCON/cache work writes sanitized intent first and a correlated success, failure, or partial outcome afterward. Audit-outcome persistence errors retain truthful completed/partial state, and legacy wrappers preserve their observable behavior.
|
||||
- Ban/unban use observed active-ban state; trade-lock uses observed sanction/settings state. Passwords, hashes, API keys, and secrets are excluded from canonical evidence.
|
||||
- Shared legacy bulk paths preserve original order, duplicates, totals, and iteration with no service-side 100-item cap. The <=100 bound remains in command schemas. Trade lock has no invented hierarchy gate and its missing-user wrapper message remains exactly `User not found`.
|
||||
- Original `StaffActivities` side effects are retained for bulk ban/unban/currency/badge, guild disband, VPN, and trade lock. Missing word-filter deletion still reloads local cache, sends RCON refresh, and returns legacy success.
|
||||
- The nine registered pages now expose capability-gated, accessible command forms backed by `executeHousekeepingCommand`; no inert command spans remain. Edit submits a mutation, list inputs come from bounded URL search parameters, and the dynamic preview route passes them through. Atomic Task 11 queries keep their fail-closed contracts; the impossible synthetic partial state was removed. A real Next loading route was added.
|
||||
- The foundation contract allows only the exact same-domain edges required here: each People page to the shared People command form, the form to the single housekeeping command action, and page-state to the People `ListInput` model. No wildcard or prefix relaxation was introduced.
|
||||
|
||||
### Final verification after review fixes
|
||||
|
||||
```text
|
||||
Focused wrapper/command/page/service/route/auth/audit/staff-smoke matrix
|
||||
Test Files 24 passed (24)
|
||||
Tests 187 passed (187)
|
||||
|
||||
pnpm test:housekeeping
|
||||
Test Files 58 passed (58)
|
||||
Tests 482 passed (482)
|
||||
|
||||
pnpm test
|
||||
Test Files 206 passed | 3 skipped (209)
|
||||
Tests 1321 passed | 5 skipped (1326)
|
||||
|
||||
pnpm typecheck
|
||||
tsc --noEmit
|
||||
Exit 0
|
||||
|
||||
pnpm exec biome check --formatter-enabled=false <40 exact changed Task 12 source files>
|
||||
Checked 40 files. No fixes applied.
|
||||
|
||||
git diff --check e1b31ff7738eb5cc59e7765c8ed7290d62130972 --
|
||||
Exit 0
|
||||
```
|
||||
|
||||
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
|
||||
@@ -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,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,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 },
|
||||
);
|
||||
|
||||
@@ -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,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,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() }));
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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() !== "",
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
|
||||
|
||||
export default function HousekeepingPreviewLoading() {
|
||||
return (
|
||||
<div data-housekeeping-state="loading">
|
||||
<HousekeepingPageState
|
||||
state="loading"
|
||||
title="Loading housekeeping workflow"
|
||||
description="Fetching the latest authorized data."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handl
|
||||
|
||||
export default async function HousekeepingPreviewRoutePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ domain: string; segments?: readonly string[] }>;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { domain, segments = [] } = await params;
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
@@ -39,5 +41,9 @@ export default async function HousekeepingPreviewRoutePage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
return handler.render({ context, match });
|
||||
return handler.render({
|
||||
context,
|
||||
match,
|
||||
...(searchParams ? { searchParams: await searchParams } : {}),
|
||||
});
|
||||
}
|
||||
@@ -43,8 +43,8 @@ function communityCommand<I>(
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
|
||||
@@ -165,12 +165,11 @@ describe("People user commands", () => {
|
||||
describe("People mutation service boundary", () => {
|
||||
it("fails closed before the adapter when the exact capability is absent", async () => {
|
||||
const execute = vi.fn(async () => ({ before: null, after: null }));
|
||||
const service = createPeopleMutationService({ execute });
|
||||
const service = createPeopleMutationService({ execute }, async () =>
|
||||
capabilityContext([PERMS.USERS_EDIT]),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "denied-people",
|
||||
},
|
||||
{ expectedActorId: 42, correlationId: "denied-people" },
|
||||
"user.ban",
|
||||
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
|
||||
);
|
||||
@@ -190,11 +189,11 @@ describe("People mutation service boundary", () => {
|
||||
after: { rank: 3 },
|
||||
}),
|
||||
};
|
||||
const success = await createPeopleMutationService(snapshotAdapter).execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "snapshot-people",
|
||||
},
|
||||
const success = await createPeopleMutationService(
|
||||
snapshotAdapter,
|
||||
async () => capabilityContext([PERMS.USERS_EDIT]),
|
||||
).execute(
|
||||
{ expectedActorId: 42, correlationId: "snapshot-people" },
|
||||
"user.update",
|
||||
{ userId: 7, fields: { rank: 3 } },
|
||||
);
|
||||
@@ -208,11 +207,11 @@ describe("People mutation service boundary", () => {
|
||||
throw new Error("database password=secret");
|
||||
},
|
||||
};
|
||||
const failure = await createPeopleMutationService(failureAdapter).execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "failed-people",
|
||||
},
|
||||
const failure = await createPeopleMutationService(
|
||||
failureAdapter,
|
||||
async () => capabilityContext([PERMS.USERS_EDIT]),
|
||||
).execute(
|
||||
{ expectedActorId: 42, correlationId: "failed-people" },
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Ready" } },
|
||||
);
|
||||
|
||||
@@ -53,8 +53,8 @@ function userCommand<I>(
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
|
||||
@@ -10,58 +10,71 @@ import {
|
||||
type PeopleCommunityQueryData,
|
||||
peopleCommunityQuery,
|
||||
} from "../queries/community";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface PeopleCommunityPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleCommunityQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
function empty(data: PeopleCommunityQueryData) {
|
||||
return data.kind !== "guild" && data.page.items.length === 0;
|
||||
}
|
||||
|
||||
function SearchForm() {
|
||||
return (
|
||||
<form method="get" className="flex gap-2">
|
||||
<input name="search" maxLength={100} aria-label="Search community" />
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeopleCommunityPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleCommunityPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Community"
|
||||
description="Review online users and guild ownership."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={empty}
|
||||
>
|
||||
{(data) => {
|
||||
if (data.kind === "online")
|
||||
return (
|
||||
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
{data.page.items.map((user) => (
|
||||
<li key={user.id}>
|
||||
<a href={user.href}>{user.username}</a> - {user.motto}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="space-y-3">
|
||||
<SearchForm />
|
||||
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
{data.page.items.map((user) => (
|
||||
<li key={user.id}>
|
||||
<a href={user.href}>{user.username}</a> - {user.motto}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
if (data.kind === "guilds")
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((guild) => (
|
||||
<li
|
||||
key={guild.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<a href={guild.href}>{guild.name}</a>
|
||||
<p>{guild.memberCount} members</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="space-y-3">
|
||||
<SearchForm />
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((guild) => (
|
||||
<li
|
||||
key={guild.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<a href={guild.href}>{guild.name}</a>
|
||||
<p>{guild.memberCount} members</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2>{data.guild.name}</h2>
|
||||
<p>{data.guild.description}</p>
|
||||
<ul>
|
||||
@@ -72,9 +85,12 @@ export function PeopleCommunityPage({
|
||||
))}
|
||||
</ul>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.guild.disband">
|
||||
Disband guild
|
||||
</span>
|
||||
<PeopleCommandForm
|
||||
commandId="people.guild.disband"
|
||||
buttonLabel="Disband guild"
|
||||
input={{ guildId: data.guild.id }}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
@@ -101,7 +117,10 @@ export async function renderPeopleCommunityPage(input: HousekeepingPageInput) {
|
||||
})()
|
||||
: routeId === "people.community.online" ||
|
||||
routeId === "people.community.guilds"
|
||||
? peopleCommunityQuery.run(input.context, { routeId, list: {} })
|
||||
? peopleCommunityQuery.run(input.context, {
|
||||
routeId,
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
})
|
||||
: Promise.resolve(
|
||||
fail(
|
||||
"NOT_FOUND",
|
||||
|
||||
@@ -4,53 +4,60 @@ import type {
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||
|
||||
interface PeopleMultiAccountsPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleMultiAccountsPage({
|
||||
context: _context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleMultiAccountsPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Multi-account clusters"
|
||||
description="Review bounded account clusters grouped by current IP."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={(data) =>
|
||||
data.kind === "multi-accounts" && data.page.items.length === 0
|
||||
}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "multi-accounts" ? (
|
||||
<ul className="space-y-3">
|
||||
{data.page.items.map((cluster) => (
|
||||
<li
|
||||
key={cluster.key}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
{cluster.key}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{cluster.accountCount} accounts
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
|
||||
{cluster.accounts.map((account) => (
|
||||
<li key={account.id}>
|
||||
<a href={account.href}>{account.username}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="space-y-3">
|
||||
<form method="get" className="flex gap-2">
|
||||
<input
|
||||
name="search"
|
||||
maxLength={100}
|
||||
aria-label="Search IP clusters"
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<ul className="space-y-3">
|
||||
{data.page.items.map((cluster) => (
|
||||
<li
|
||||
key={cluster.key}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
{cluster.key}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{cluster.accountCount} accounts
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
|
||||
{cluster.accounts.map((account) => (
|
||||
<li key={account.id}>
|
||||
<a href={account.href}>{account.username}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
@@ -62,7 +69,7 @@ export async function renderPeopleMultiAccountsPage(
|
||||
) {
|
||||
const result = await peopleUsersQuery.run(input.context, {
|
||||
routeId: "people.users.multi-accounts",
|
||||
list: {},
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return <PeopleMultiAccountsPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -2,12 +2,60 @@ import type { ReactNode } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
||||
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
|
||||
import type { ListInput } from "../models";
|
||||
|
||||
export type PeopleSearchParams = Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>;
|
||||
|
||||
function firstParam(
|
||||
params: PeopleSearchParams,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = params[key];
|
||||
return typeof value === "string" ? value : value?.[0];
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed)
|
||||
? Math.min(maximum, Math.max(minimum, parsed))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function parsePeopleListInput(params: PeopleSearchParams): ListInput {
|
||||
const pageSize = boundedInteger(firstParam(params, "pageSize"), 20, 1, 100);
|
||||
const page = boundedInteger(firstParam(params, "page"), 1, 1, 1_000_001);
|
||||
const search = Array.from(firstParam(params, "search") ?? "")
|
||||
.filter((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint >= 32 && codePoint !== 127;
|
||||
})
|
||||
.join("")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
return {
|
||||
search,
|
||||
pageSize,
|
||||
offset: Math.min(100_000, (page - 1) * pageSize),
|
||||
sort: (firstParam(params, "sort") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 32),
|
||||
order: firstParam(params, "direction") === "desc" ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
|
||||
interface PeoplePageFrameProps<T> {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly result?: HousekeepingResult<T>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
readonly isEmpty: (data: T) => boolean;
|
||||
readonly children: (data: T) => ReactNode;
|
||||
}
|
||||
@@ -32,7 +80,6 @@ export function PeoplePageFrame<T>({
|
||||
title,
|
||||
description,
|
||||
result,
|
||||
partialDependencies = [],
|
||||
isEmpty,
|
||||
children,
|
||||
}: PeoplePageFrameProps<T>) {
|
||||
@@ -76,20 +123,7 @@ export function PeoplePageFrame<T>({
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div
|
||||
data-housekeeping-state={
|
||||
partialDependencies.length > 0 ? "partial" : "ready"
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
{partialDependencies.length > 0 ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title="Some People data is unavailable"
|
||||
description={`Unavailable: ${partialDependencies.join(", ")}`}
|
||||
/>
|
||||
) : null}
|
||||
<div data-housekeeping-state="ready" className="space-y-4">
|
||||
{children(result.data)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
|
||||
export interface PeopleCommandField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly type: "text" | "number" | "number-list" | "checkbox" | "select";
|
||||
readonly required?: boolean;
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
readonly maxLength?: number;
|
||||
readonly defaultValue?: string | number;
|
||||
readonly options?: readonly Readonly<{
|
||||
value: string | number;
|
||||
label: string;
|
||||
}>[];
|
||||
}
|
||||
|
||||
interface PeopleCommandFormProps {
|
||||
readonly commandId: string;
|
||||
readonly buttonLabel: string;
|
||||
readonly input: Readonly<Record<string, unknown>>;
|
||||
readonly fields?: readonly PeopleCommandField[];
|
||||
readonly requiresReason?: boolean;
|
||||
readonly includeReasonInInput?: boolean;
|
||||
readonly nestFields?: boolean;
|
||||
}
|
||||
|
||||
function parseField(field: PeopleCommandField, formData: FormData): unknown {
|
||||
if (field.type === "checkbox") return formData.get(field.name) === "on";
|
||||
const raw = String(formData.get(field.name) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (field.type === "number") {
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) return 0;
|
||||
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
||||
}
|
||||
if (field.type === "select") {
|
||||
const selected = field.options?.find(
|
||||
(option) => String(option.value) === raw,
|
||||
)?.value;
|
||||
return typeof selected === "number" ? selected : raw.slice(0, 500);
|
||||
}
|
||||
if (field.type === "number-list") {
|
||||
return raw
|
||||
.split(/[\s,]+/u)
|
||||
.filter(Boolean)
|
||||
.slice(0, 100)
|
||||
.map(Number)
|
||||
.filter((value) => Number.isSafeInteger(value) && value > 0);
|
||||
}
|
||||
return raw.slice(0, field.maxLength ?? 500);
|
||||
}
|
||||
|
||||
const initialState: HousekeepingResult<unknown> | null = null;
|
||||
|
||||
export function PeopleCommandForm({
|
||||
commandId,
|
||||
buttonLabel,
|
||||
input,
|
||||
fields = [],
|
||||
requiresReason = false,
|
||||
includeReasonInInput = false,
|
||||
nestFields = false,
|
||||
}: PeopleCommandFormProps) {
|
||||
const [result, submit, pending] = useActionState(
|
||||
async (
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
formData: FormData,
|
||||
) => {
|
||||
const dynamic = Object.fromEntries(
|
||||
fields.map((field) => [field.name, parseField(field, formData)]),
|
||||
);
|
||||
const reason = String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
const commandInput = nestFields
|
||||
? { ...input, fields: dynamic }
|
||||
: {
|
||||
...input,
|
||||
...dynamic,
|
||||
...(includeReasonInInput ? { reason } : {}),
|
||||
};
|
||||
const { executeHousekeepingCommand } = await import(
|
||||
"@/actions/housekeeping-command"
|
||||
);
|
||||
return executeHousekeepingCommand({
|
||||
commandId,
|
||||
input: commandInput,
|
||||
...(requiresReason ? { reason } : {}),
|
||||
});
|
||||
},
|
||||
initialState,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={submit}
|
||||
data-housekeeping-command={commandId}
|
||||
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<label
|
||||
key={field.name}
|
||||
htmlFor={`${commandId}-${field.name}`}
|
||||
className="block text-sm"
|
||||
>
|
||||
{field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "select" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={field.defaultValue}
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field.label}
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
defaultValue={field.defaultValue}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{requiresReason ? (
|
||||
<label htmlFor={`${commandId}-reason`} className="block text-sm">
|
||||
Reason
|
||||
<textarea
|
||||
id={`${commandId}-reason`}
|
||||
name="reason"
|
||||
required
|
||||
maxLength={1000}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? "Working..." : buttonLabel}
|
||||
</button>
|
||||
{result ? (
|
||||
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
|
||||
{result.ok
|
||||
? `Completed (${result.correlationId})`
|
||||
: `Failed: ${result.error.messageKey} (${result.correlationId})`}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
vi.mock("@/actions/housekeeping-command", () => ({
|
||||
executeHousekeepingCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
@@ -19,6 +25,7 @@ import {
|
||||
} from "../route-handlers";
|
||||
import { PeopleCommunityPage } from "./community";
|
||||
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
||||
import { parsePeopleListInput } from "./page-state";
|
||||
import { PeopleStaffPage } from "./staff";
|
||||
import { PeopleUserDetailPage } from "./user-detail";
|
||||
import { PeopleUserEditPage } from "./user-edit";
|
||||
@@ -77,12 +84,11 @@ type PageCase = {
|
||||
const cases: readonly PageCase[] = [
|
||||
{
|
||||
name: "users",
|
||||
render: (result, partialDependencies) =>
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleUsersPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
@@ -96,12 +102,11 @@ const cases: readonly PageCase[] = [
|
||||
},
|
||||
{
|
||||
name: "user-detail",
|
||||
render: (result, partialDependencies) =>
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleUserDetailPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: { kind: "user", user: { ...detail, sanctions: [] } },
|
||||
@@ -124,12 +129,11 @@ const cases: readonly PageCase[] = [
|
||||
},
|
||||
{
|
||||
name: "user-edit",
|
||||
render: (result, partialDependencies) =>
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleUserEditPage
|
||||
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
|
||||
@@ -137,12 +141,11 @@ const cases: readonly PageCase[] = [
|
||||
},
|
||||
{
|
||||
name: "multi-accounts",
|
||||
render: (result, partialDependencies) =>
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleMultiAccountsPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
@@ -161,12 +164,11 @@ const cases: readonly PageCase[] = [
|
||||
},
|
||||
{
|
||||
name: "community",
|
||||
render: (result, partialDependencies) =>
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleCommunityPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleCommunityQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
@@ -197,12 +199,11 @@ const cases: readonly PageCase[] = [
|
||||
},
|
||||
{
|
||||
name: "staff",
|
||||
render: (result, partialDependencies) =>
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleStaffPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleStaffQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
@@ -231,7 +232,7 @@ const cases: readonly PageCase[] = [
|
||||
];
|
||||
|
||||
describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
||||
it("renders loading, forbidden, dependency error, empty, partial, and ready states", () => {
|
||||
it("renders loading, forbidden, dependency error, empty, and ready states", () => {
|
||||
expect(render()).toContain('data-housekeeping-state="loading"');
|
||||
expect(
|
||||
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
|
||||
@@ -250,9 +251,6 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
||||
? (empty as HousekeepingResult<unknown>)
|
||||
: ok(empty, correlationId);
|
||||
expect(render(emptyResult)).toContain('data-housekeeping-state="empty"');
|
||||
expect(render(ok(ready, correlationId), ["secondary"])).toContain(
|
||||
'data-housekeeping-state="partial"',
|
||||
);
|
||||
expect(render(ok(ready, correlationId))).toContain(
|
||||
'data-housekeeping-state="ready"',
|
||||
);
|
||||
@@ -310,7 +308,59 @@ describe("People primary route registration", () => {
|
||||
/>,
|
||||
);
|
||||
expect(editorHtml).toContain('href="/ase/people/users/7/edit"');
|
||||
expect(editorHtml).toContain("people.user.update");
|
||||
expect(editorHtml).toContain("<form");
|
||||
expect(editorHtml).not.toContain("<span data-housekeeping-command");
|
||||
expect(editorHtml).not.toContain("/admin");
|
||||
|
||||
const editHtml = renderToStaticMarkup(
|
||||
<PeopleUserEditPage
|
||||
context={capabilityContext([PERMS.USERS_VIEW, PERMS.USERS_EDIT])}
|
||||
result={ok({ kind: "user", user: detail }, correlationId)}
|
||||
/>,
|
||||
);
|
||||
expect(editHtml).toContain("people.user.update");
|
||||
expect(editHtml).toContain("<form");
|
||||
});
|
||||
});
|
||||
describe("People URL list input", () => {
|
||||
it("bounds and normalizes search, page, pageSize, sort, and direction", () => {
|
||||
expect(
|
||||
parsePeopleListInput({
|
||||
search: [" Alice ", "ignored"],
|
||||
page: "999999999999",
|
||||
pageSize: "5000",
|
||||
sort: "username",
|
||||
direction: "desc",
|
||||
}),
|
||||
).toEqual({
|
||||
search: "Alice",
|
||||
pageSize: 100,
|
||||
offset: 100000,
|
||||
sort: "username",
|
||||
order: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("People actionable form contract", () => {
|
||||
it("submits bounded command input and reason through the existing server action", () => {
|
||||
const source = readFileSync(
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("executeHousekeepingCommand({");
|
||||
expect(source).toContain("commandId");
|
||||
expect(source).toContain("input: commandInput");
|
||||
expect(source).toContain("{ reason }");
|
||||
expect(source).toContain("action={submit}");
|
||||
expect(source).not.toContain("<span data-housekeeping-command");
|
||||
});
|
||||
|
||||
it("provides a real Next loading boundary", () => {
|
||||
const source = readFileSync(
|
||||
"src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain('data-housekeeping-state="loading"');
|
||||
expect(source).toContain('state="loading"');
|
||||
});
|
||||
});
|
||||
@@ -7,64 +7,123 @@ import {
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface PeopleStaffPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleStaffQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleStaffPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleStaffPageProps) {
|
||||
export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Staff"
|
||||
description="Review staff applications and team definitions."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={(data) => data.page.items.length === 0}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "applications" ? (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((application) => (
|
||||
<li
|
||||
key={application.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2>{application.username ?? `User #${application.userId}`}</h2>
|
||||
<p>{application.content}</p>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.application.decide">
|
||||
Dismiss application
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : data.kind === "teams" ? (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((team) => (
|
||||
<li
|
||||
key={team.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2>{team.name}</h2>
|
||||
<p>{team.jobDescription ?? "No job description"}</p>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.team.change">
|
||||
Team controls
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null
|
||||
}
|
||||
{(data) => (
|
||||
<div className="space-y-3">
|
||||
<form method="get" className="flex gap-2">
|
||||
<input
|
||||
name="search"
|
||||
maxLength={100}
|
||||
aria-label="Search staff workflow"
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
{data.kind === "applications" ? (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((application) => (
|
||||
<li
|
||||
key={application.id}
|
||||
className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2>
|
||||
{application.username ?? `User #${application.userId}`}
|
||||
</h2>
|
||||
<p>{application.content}</p>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.application.decide"
|
||||
buttonLabel="Dismiss application"
|
||||
input={{
|
||||
applicationId: application.id,
|
||||
decision: "dismiss",
|
||||
}}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : data.kind === "teams" ? (
|
||||
<div className="space-y-3">
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.team.change"
|
||||
buttonLabel="Create team"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "rankName",
|
||||
label: "Team name",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "badge",
|
||||
label: "Badge",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "jobDescription",
|
||||
label: "Job description",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "staffColor",
|
||||
label: "Staff color",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
defaultValue: "#327fa8",
|
||||
},
|
||||
{
|
||||
name: "hiddenRank",
|
||||
label: "Hidden rank",
|
||||
type: "checkbox",
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((team) => (
|
||||
<li
|
||||
key={team.id}
|
||||
className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2>{team.name}</h2>
|
||||
<p>{team.jobDescription ?? "No job description"}</p>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.team.change"
|
||||
buttonLabel="Delete team"
|
||||
input={{ action: "delete", teamId: team.id }}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +132,10 @@ export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId;
|
||||
const result =
|
||||
routeId === "people.staff.applications" || routeId === "people.staff.teams"
|
||||
? await peopleStaffQuery.run(input.context, { routeId, list: {} })
|
||||
? await peopleStaffQuery.run(input.context, {
|
||||
routeId,
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
})
|
||||
: fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
|
||||
@@ -8,24 +8,22 @@ import {
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface PeopleUserDetailPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleUserDetailPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleUserDetailPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="User detail"
|
||||
description="Review account state, permissions, and sanctions."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={() => false}
|
||||
>
|
||||
{(data) =>
|
||||
@@ -37,12 +35,7 @@ export function PeopleUserDetailPage({
|
||||
{data.user.username}
|
||||
</h2>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<a
|
||||
href={`${data.user.href}/edit`}
|
||||
data-housekeeping-command="people.user.update"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<a href={`${data.user.href}/edit`}>Edit</a>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
||||
@@ -65,21 +58,6 @@ export function PeopleUserDetailPage({
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<span data-housekeeping-command="people.user.ban">Ban</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.user.disconnect">
|
||||
Disconnect
|
||||
</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_RESET_PASSWORD) ? (
|
||||
<span data-housekeeping-command="people.user.reset-password">
|
||||
Reset password
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
@@ -102,6 +80,126 @@ export function PeopleUserDetailPage({
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="space-y-3 lg:col-span-2">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
Account actions
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.ban"
|
||||
buttonLabel="Ban user"
|
||||
input={{ userId: data.user.id, type: "account" }}
|
||||
fields={[
|
||||
{
|
||||
name: "duration",
|
||||
label: "Duration hours",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 87_600,
|
||||
defaultValue: 0,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
includeReasonInInput
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.unban"
|
||||
buttonLabel="Unban user"
|
||||
input={{ userId: data.user.id }}
|
||||
requiresReason
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.alert"
|
||||
buttonLabel="Send alert"
|
||||
input={{ userId: data.user.id }}
|
||||
fields={[
|
||||
{
|
||||
name: "message",
|
||||
label: "Message",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.disconnect"
|
||||
buttonLabel="Disconnect"
|
||||
input={{ userId: data.user.id }}
|
||||
requiresReason
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.mute"
|
||||
buttonLabel="Mute"
|
||||
input={{ userId: data.user.id }}
|
||||
fields={[
|
||||
{
|
||||
name: "duration",
|
||||
label: "Duration seconds",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 87_600,
|
||||
defaultValue: 0,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.unmute"
|
||||
buttonLabel="Unmute"
|
||||
input={{ userId: data.user.id }}
|
||||
requiresReason
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.send-currency"
|
||||
buttonLabel="Send credits"
|
||||
input={{ userId: data.user.id }}
|
||||
fields={[
|
||||
{
|
||||
name: "amount",
|
||||
label: "Credits",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 1_000_000,
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.trade-lock"
|
||||
buttonLabel="Set trade lock"
|
||||
input={{ userId: data.user.id }}
|
||||
fields={[
|
||||
{
|
||||
name: "untilUnix",
|
||||
label: "Locked until (Unix seconds, 0 clears)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 2_147_483_647,
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_RESET_PASSWORD) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.reset-password"
|
||||
buttonLabel="Reset password"
|
||||
input={{ userId: data.user.id }}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -7,80 +7,74 @@ import {
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import {
|
||||
type PeopleCommandField,
|
||||
PeopleCommandForm,
|
||||
} from "./people-command-form";
|
||||
|
||||
interface PeopleUserEditPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleUserEditPage({
|
||||
context: _context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleUserEditPageProps) {
|
||||
export function PeopleUserEditPage({ result }: PeopleUserEditPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Edit user"
|
||||
description="Update bounded account fields through an audited command."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={() => false}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "user" ? (
|
||||
<form
|
||||
data-housekeeping-command="people.user.update"
|
||||
className="space-y-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<input type="hidden" name="userId" value={data.user.id} />
|
||||
<label className="block text-sm">
|
||||
Username
|
||||
<input
|
||||
name="username"
|
||||
defaultValue={data.user.username}
|
||||
maxLength={20}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
{data.user.mail !== null ? (
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
<input
|
||||
name="mail"
|
||||
type="email"
|
||||
defaultValue={data.user.mail}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
Motto
|
||||
<input
|
||||
name="motto"
|
||||
defaultValue={data.user.motto}
|
||||
maxLength={127}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Rank
|
||||
<select
|
||||
name="rank"
|
||||
defaultValue={data.user.rank}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{data.user.permission.ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save user</button>
|
||||
</form>
|
||||
) : null
|
||||
}
|
||||
{(data) => {
|
||||
if (data.kind !== "user") return null;
|
||||
const fields: PeopleCommandField[] = [
|
||||
{
|
||||
name: "username",
|
||||
label: "Username",
|
||||
type: "text",
|
||||
defaultValue: data.user.username,
|
||||
maxLength: 20,
|
||||
required: true,
|
||||
},
|
||||
...(data.user.mail !== null
|
||||
? [
|
||||
{
|
||||
name: "mail",
|
||||
label: "Email",
|
||||
type: "text",
|
||||
defaultValue: data.user.mail,
|
||||
maxLength: 255,
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "motto",
|
||||
label: "Motto",
|
||||
type: "text",
|
||||
defaultValue: data.user.motto,
|
||||
maxLength: 127,
|
||||
},
|
||||
{
|
||||
name: "rank",
|
||||
label: "Rank",
|
||||
type: "select",
|
||||
defaultValue: data.user.rank,
|
||||
options: data.user.permission.ranks.map((rank) => ({
|
||||
value: rank.id,
|
||||
label: rank.name,
|
||||
})),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<PeopleCommandForm
|
||||
commandId="people.user.update"
|
||||
buttonLabel="Save user"
|
||||
input={{ userId: data.user.id }}
|
||||
fields={fields}
|
||||
nestFields
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,72 +5,143 @@ import type {
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface PeopleUsersPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleUsersPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleUsersPageProps) {
|
||||
const userIdsField = {
|
||||
name: "userIds",
|
||||
label: "User IDs (comma or space separated)",
|
||||
type: "number-list" as const,
|
||||
required: true,
|
||||
maxLength: 1200,
|
||||
};
|
||||
|
||||
export function PeopleUsersPage({ context, result }: PeopleUsersPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Users"
|
||||
description="Search accounts and open the canonical People workflow."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "users" ? (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{data.page.total} matching users
|
||||
</p>
|
||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{data.page.items.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex flex-wrap items-center gap-3 py-3 text-sm"
|
||||
>
|
||||
<a
|
||||
className="font-medium text-[var(--admin-text)]"
|
||||
href={user.href}
|
||||
<div className="space-y-4">
|
||||
<form method="get" className="flex flex-wrap gap-2">
|
||||
<label>
|
||||
Search users
|
||||
<input name="search" maxLength={100} className="ml-2" />
|
||||
</label>
|
||||
<input type="hidden" name="pageSize" value={data.page.pageSize} />
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<PeopleCommandForm
|
||||
commandId="people.users.bulk-ban"
|
||||
buttonLabel="Ban users"
|
||||
input={{}}
|
||||
fields={[
|
||||
userIdsField,
|
||||
{
|
||||
name: "duration",
|
||||
label: "Duration seconds",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 315_360_000,
|
||||
defaultValue: 0,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
includeReasonInInput
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.users.bulk-unban"
|
||||
buttonLabel="Unban users"
|
||||
input={{}}
|
||||
fields={[userIdsField]}
|
||||
requiresReason
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.users.bulk-currency"
|
||||
buttonLabel="Send currency"
|
||||
input={{}}
|
||||
fields={[
|
||||
userIdsField,
|
||||
{
|
||||
name: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 1_000_000,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
label: "Currency",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "credits", label: "Credits" },
|
||||
{ value: "pixels", label: "Duckets" },
|
||||
{ value: "points", label: "Points" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.users.bulk-badge"
|
||||
buttonLabel="Give badge"
|
||||
input={{}}
|
||||
fields={[
|
||||
userIdsField,
|
||||
{
|
||||
name: "badgeCode",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
maxLength: 20,
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{data.page.total} matching users
|
||||
</p>
|
||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{data.page.items.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex flex-wrap items-center gap-3 py-3 text-sm"
|
||||
>
|
||||
{user.username}
|
||||
</a>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
Rank {user.rank}
|
||||
</span>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{user.online ? "Online" : "Offline"}
|
||||
</span>
|
||||
{user.mail !== null ? <span>{user.mail}</span> : null}
|
||||
{user.ipCurrent !== null ? (
|
||||
<span>{user.ipCurrent}</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<a
|
||||
href={`${user.href}/edit`}
|
||||
data-housekeeping-command="people.user.update"
|
||||
className="font-medium text-[var(--admin-text)]"
|
||||
href={user.href}
|
||||
>
|
||||
Edit
|
||||
{user.username}
|
||||
</a>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<span data-housekeeping-command="people.user.ban">
|
||||
Ban controls available
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<span>Rank {user.rank}</span>
|
||||
<span>{user.online ? "Online" : "Offline"}</span>
|
||||
{user.mail !== null ? <span>{user.mail}</span> : null}
|
||||
{user.ipCurrent !== null ? (
|
||||
<span>{user.ipCurrent}</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<a href={`${user.href}/edit`}>Edit</a>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
@@ -80,7 +151,7 @@ export function PeopleUsersPage({
|
||||
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
|
||||
const result = await peopleUsersQuery.run(input.context, {
|
||||
routeId: "people.users.list",
|
||||
list: {},
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return <PeopleUsersPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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(),
|
||||
}),
|
||||
);
|
||||
|
||||
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/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: audit,
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
||||
rcon: { alertUser },
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function context(): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_EDIT,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_EDIT),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_EDIT),
|
||||
};
|
||||
}
|
||||
|
||||
const invocation = {
|
||||
correlationId: "audit-contract",
|
||||
expectedActorId: 42,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe("People external audit contract", () => {
|
||||
it("blocks the external operation when intent persistence fails", async () => {
|
||||
audit.mockRejectedValueOnce(new Error("intent unavailable"));
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
expect(alertUser).not.toHaveBeenCalled();
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: "intent" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists a correlated failure outcome when the external operation fails", async () => {
|
||||
alertUser.mockResolvedValue(false);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(
|
||||
audit.mock.calls.map((call) => {
|
||||
const entry = call[0] as AuditEntry;
|
||||
return {
|
||||
outcome: entry.outcome,
|
||||
correlationId: entry.correlationId,
|
||||
};
|
||||
}),
|
||||
).toEqual([
|
||||
{ outcome: "intent", correlationId: "audit-contract" },
|
||||
{ outcome: "failure", correlationId: "audit-contract" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws typed completed-operation evidence 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 }),
|
||||
},
|
||||
});
|
||||
expect(alertUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
insertValues: vi.fn(),
|
||||
logStaffActivity: vi.fn(),
|
||||
rcon: {
|
||||
alertUser: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
giveBadge: vi.fn(),
|
||||
giveCredits: vi.fn(),
|
||||
giveDuckets: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
setTradeLock: vi.fn(),
|
||||
updateWordFilter: vi.fn(),
|
||||
},
|
||||
reloadSettings: vi.fn(),
|
||||
reloadWordFilter: vi.fn(),
|
||||
resolveServerContext: vi.fn(),
|
||||
selectQueue: [] as unknown[][],
|
||||
transaction: vi.fn(),
|
||||
updateWhere: vi.fn(),
|
||||
}));
|
||||
|
||||
function mutationPromise(
|
||||
result: unknown = [{ insertId: 12, affectedRows: 2 }],
|
||||
) {
|
||||
return Object.assign(Promise.resolve(result), {
|
||||
onDuplicateKeyUpdate: vi.fn(async () => result),
|
||||
});
|
||||
}
|
||||
|
||||
function selectResult() {
|
||||
const value = mocks.selectQueue.shift() ?? [];
|
||||
return Object.assign(Promise.resolve(value), {
|
||||
limit: vi.fn(async () => value),
|
||||
orderBy: vi.fn(() =>
|
||||
Object.assign(Promise.resolve(value), {
|
||||
limit: vi.fn(async () => value),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function databaseFacade() {
|
||||
const facade = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(selectResult),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: mocks.insertValues.mockImplementation(() => mutationPromise()),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({
|
||||
where: mocks.deleteWhere.mockResolvedValue([{ affectedRows: 2 }]),
|
||||
})),
|
||||
};
|
||||
return facade;
|
||||
}
|
||||
|
||||
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/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
const tx = databaseFacade();
|
||||
const root = databaseFacade();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
...root,
|
||||
transaction: mocks.transaction.mockImplementation(async (callback) =>
|
||||
callback(tx),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: mocks.audit,
|
||||
}));
|
||||
vi.mock("@/lib/services/moderation", () => ({
|
||||
reloadWordFilter: mocks.reloadWordFilter,
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon }));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: {
|
||||
get: vi.fn(
|
||||
async (key: string) =>
|
||||
({
|
||||
vpn_block_enabled: "0",
|
||||
vpn_provider: "none",
|
||||
vpn_api_key: "stored-secret",
|
||||
vpn_block_message: "Blocked",
|
||||
})[key],
|
||||
),
|
||||
reload: mocks.reloadSettings,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: mocks.logStaffActivity,
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function context(): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
const invocation = {
|
||||
correlationId: "production-workflow",
|
||||
expectedActorId: 42,
|
||||
legacy: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.selectQueue.length = 0;
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.reloadSettings.mockResolvedValue(undefined);
|
||||
for (const call of Object.values(mocks.rcon)) call.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("People production workflow adapter", () => {
|
||||
it("preserves legacy bulk order, duplicates, totals, and StaffActivities", async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, index) => (index % 2) + 1);
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"users.bulk-unban",
|
||||
{ userIds: ids },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { userIds: ids }, after: { completed: 2, total: 101 } },
|
||||
});
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "bulk_unban",
|
||||
description: "Unbanned 2 user(s)",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses observed trade-lock state, no target hierarchy, RCON, and legacy activity", async () => {
|
||||
mocks.selectQueue.push(
|
||||
[
|
||||
{
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
rank: 7,
|
||||
motto: "",
|
||||
credits: 0,
|
||||
pixels: 0,
|
||||
online: "1",
|
||||
},
|
||||
],
|
||||
[{ id: 3, tradeLockedUntil: 100, reason: "existing" }],
|
||||
[{ canTrade: "0", tradelockAmount: 4 }],
|
||||
);
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.trade-lock",
|
||||
{ userId: 7, untilUnix: 200 },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { tradeLockedUntil: 100, canTrade: "0", tradelockAmount: 4 },
|
||||
after: { tradeLockedUntil: 200, canTrade: "0", tradelockAmount: 5 },
|
||||
},
|
||||
});
|
||||
expect(mocks.rcon.setTradeLock).toHaveBeenCalledWith(7, true);
|
||||
expect(mocks.rcon.disconnectUser).toHaveBeenCalledWith(7, "Alice");
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "trade_lock", targetId: 7 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs guild, application, team, and IP database workflows transactionally", async () => {
|
||||
mocks.selectQueue.push([{ id: 9, name: "Builders", userId: 7 }], []);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "guild.disband", {
|
||||
guildId: 9,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "guild_disband", targetId: 9 }),
|
||||
);
|
||||
|
||||
mocks.selectQueue.push([
|
||||
{ id: 5n, userId: 7, rankId: 4, content: "Ready" },
|
||||
]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "application.decide", {
|
||||
applicationId: 5,
|
||||
decision: "dismiss",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
mocks.selectQueue.push([
|
||||
{
|
||||
id: 4n,
|
||||
rankName: "MOD",
|
||||
badge: null,
|
||||
jobDescription: null,
|
||||
hiddenRank: false,
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "team.change", {
|
||||
action: "delete",
|
||||
teamId: 4,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
mocks.selectQueue.push([{ id: 3n, ipAddress: "198.51.100.3", asn: null }]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "ip.action", {
|
||||
action: "delete-blacklist",
|
||||
id: 3,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(
|
||||
mocks.audit.mock.calls.filter((call) => call[1]).length,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("keeps VPN secrets out of audit while reloading settings and logging legacy activity", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"vpn.configure",
|
||||
{
|
||||
enabled: true,
|
||||
provider: "proxycheck",
|
||||
apiKey: "new-secret",
|
||||
blockMessage: "No VPN",
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(mocks.reloadSettings).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "vpn_update" }),
|
||||
);
|
||||
const serialized = JSON.stringify(
|
||||
mocks.audit.mock.calls.map((call) => call[0] as AuditEntry),
|
||||
);
|
||||
expect(serialized).not.toContain("new-secret");
|
||||
expect(serialized).not.toContain("stored-secret");
|
||||
});
|
||||
|
||||
it("refreshes local and RCON wordfilter state when delete target is already missing", async () => {
|
||||
mocks.selectQueue.push([]);
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"word-filter.update",
|
||||
{ action: "delete", id: 99 },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: null, after: null },
|
||||
});
|
||||
expect(mocks.reloadWordFilter).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rcon.updateWordFilter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
alertUser: vi.fn(),
|
||||
selectLimit: vi.fn(),
|
||||
}));
|
||||
const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted(
|
||||
() => ({
|
||||
audit: vi.fn(),
|
||||
alertUser: vi.fn(),
|
||||
resolveServerContext: vi.fn(),
|
||||
selectLimit: 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) => {
|
||||
@@ -62,6 +68,7 @@ const target = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveServerContext.mockResolvedValue(capabilityContext([]));
|
||||
selectLimit.mockResolvedValue([target]);
|
||||
alertUser.mockResolvedValue(true);
|
||||
audit.mockResolvedValue(undefined);
|
||||
@@ -70,7 +77,7 @@ beforeEach(() => {
|
||||
describe("People production mutation boundary", () => {
|
||||
it("rechecks authorization before any production adapter work", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{ capability: capabilityContext([]), correlationId: "production-denied" },
|
||||
{ expectedActorId: 42, correlationId: "production-denied" },
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
@@ -81,11 +88,11 @@ describe("People production mutation boundary", () => {
|
||||
});
|
||||
|
||||
it("emits sanitized before and after evidence for a successful mutation", async () => {
|
||||
resolveServerContext.mockResolvedValue(
|
||||
capabilityContext([PERMS.USERS_EDIT]),
|
||||
);
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "production-alert",
|
||||
},
|
||||
{ expectedActorId: 42, correlationId: "production-alert" },
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
@@ -107,17 +114,17 @@ describe("People production mutation boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when required audit evidence cannot be persisted", async () => {
|
||||
it("blocks the external action when required intent evidence cannot be persisted", async () => {
|
||||
resolveServerContext.mockResolvedValue(
|
||||
capabilityContext([PERMS.USERS_EDIT]),
|
||||
);
|
||||
audit.mockRejectedValue(new Error("audit database unavailable"));
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "production-audit-failure",
|
||||
},
|
||||
{ expectedActorId: 42, correlationId: "production-audit-failure" },
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
expect(alertUser).toHaveBeenCalled();
|
||||
expect(alertUser).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
|
||||
+38
-15
@@ -2,17 +2,39 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const { audit, disconnectUser, insertValues, selectLimit } = vi.hoisted(() => ({
|
||||
const {
|
||||
audit,
|
||||
disconnectUser,
|
||||
insertValues,
|
||||
resolveServerContext,
|
||||
selectLimit,
|
||||
transaction,
|
||||
txSelectLimit,
|
||||
} = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
insertValues: vi.fn(),
|
||||
resolveServerContext: vi.fn(),
|
||||
selectLimit: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
txSelectLimit: 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")>();
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: txSelectLimit })),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
@@ -22,7 +44,9 @@ vi.mock("@/lib/db", async (importOriginal) => {
|
||||
where: vi.fn(() => ({ limit: selectLimit })),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
transaction: transaction.mockImplementation(async (callback) =>
|
||||
callback(tx),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -34,30 +58,28 @@ vi.mock("@/lib/services/rcon", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
||||
rcon: { disconnectUser },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
function capabilityContext(): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
has: (slug) => slug === PERMS.USERS_BAN,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_BAN),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_BAN),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveServerContext.mockResolvedValue(capabilityContext());
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
mail: "[email protected]",
|
||||
rank: 2,
|
||||
motto: "Ready",
|
||||
credits: 100,
|
||||
@@ -65,18 +87,16 @@ beforeEach(() => {
|
||||
online: "1",
|
||||
},
|
||||
]);
|
||||
txSelectLimit.mockResolvedValue([]);
|
||||
insertValues.mockResolvedValue([{}]);
|
||||
disconnectUser.mockResolvedValue(true);
|
||||
audit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("People production reason audit", () => {
|
||||
it("persists the nonblank sanction reason with before and after evidence", async () => {
|
||||
it("persists the nonblank sanction reason with observed before and after evidence", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_BAN]),
|
||||
correlationId: "production-ban",
|
||||
},
|
||||
{ expectedActorId: 42, correlationId: "production-ban" },
|
||||
"user.ban",
|
||||
{
|
||||
userId: 7,
|
||||
@@ -87,9 +107,11 @@ describe("People production reason audit", () => {
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "people.user.ban",
|
||||
outcome: "intent",
|
||||
before: expect.objectContaining({ id: 7, banned: false }),
|
||||
after: expect.objectContaining({
|
||||
id: 7,
|
||||
@@ -97,6 +119,7 @@ describe("People production reason audit", () => {
|
||||
reason: "Repeated harassment",
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
|
||||
expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
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(),
|
||||
}),
|
||||
);
|
||||
|
||||
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/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: audit,
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
||||
rcon: { alertUser },
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe("People production server authority", () => {
|
||||
it("rejects a caller-forged capability and resolves the authenticated request actor", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
correlationId: "server-authority",
|
||||
expectedActorId: 42,
|
||||
capability: context([PERMS.USERS_EDIT]),
|
||||
} as never,
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
|
||||
expect(resolveServerContext).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
correlationId: "server-authority",
|
||||
});
|
||||
expect(selectLimit).not.toHaveBeenCalled();
|
||||
expect(alertUser).not.toHaveBeenCalled();
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const {
|
||||
audit,
|
||||
globalDeleteWhere,
|
||||
resolveServerContext,
|
||||
transaction,
|
||||
txDeleteWhere,
|
||||
txSelectLimit,
|
||||
} = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
globalDeleteWhere: vi.fn(),
|
||||
resolveServerContext: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
txDeleteWhere: vi.fn(),
|
||||
txSelectLimit: 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: txSelectLimit })),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: globalDeleteWhere })),
|
||||
transaction,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: audit,
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function context(): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_EDIT,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_EDIT),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_EDIT),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveServerContext.mockResolvedValue(context());
|
||||
txSelectLimit.mockResolvedValue([
|
||||
{ id: 9n, userId: 7, rankId: 3, content: "Application" },
|
||||
]);
|
||||
globalDeleteWhere.mockResolvedValue(undefined);
|
||||
txDeleteWhere.mockResolvedValue(undefined);
|
||||
transaction.mockImplementation(async (callback) => {
|
||||
let staged = false;
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: txSelectLimit })),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({
|
||||
where: vi.fn(async (...args) => {
|
||||
staged = true;
|
||||
return txDeleteWhere(...args);
|
||||
}),
|
||||
})),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
try {
|
||||
const value = await callback(tx);
|
||||
return { value, committed: staged };
|
||||
} catch (error) {
|
||||
throw Object.assign(error as Error, { rolledBack: staged });
|
||||
}
|
||||
});
|
||||
audit.mockRejectedValue(new Error("audit unavailable"));
|
||||
});
|
||||
|
||||
describe("People transactional database audit", () => {
|
||||
it("rolls back application deletion when canonical audit persistence fails", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{ correlationId: "transactional-audit", expectedActorId: 42 },
|
||||
"application.decide",
|
||||
{ applicationId: 9, decision: "dismiss" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(globalDeleteWhere).not.toHaveBeenCalled();
|
||||
expect(txDeleteWhere).toHaveBeenCalledTimes(1);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outcome: "success",
|
||||
before: expect.objectContaining({ id: 9 }),
|
||||
after: undefined,
|
||||
}),
|
||||
expect.objectContaining({ insert: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/community.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/community",
|
||||
]),
|
||||
@@ -38,6 +39,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/multi-accounts.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
@@ -45,6 +47,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/staff.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/staff",
|
||||
]),
|
||||
@@ -52,6 +55,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/user-detail.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
@@ -59,6 +63,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/user-edit.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
@@ -66,10 +71,19 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/users.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/page-state.tsx",
|
||||
new Set(["src/features/housekeeping/domains/people/models"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form.tsx",
|
||||
new Set(["src/actions/housekeeping-command"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||
new Set([
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||
export interface HousekeepingPageInput {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly match: HousekeepingRouteMatch;
|
||||
readonly searchParams?: Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface HousekeepingRouteHandler {
|
||||
|
||||
@@ -136,13 +136,17 @@ describe("staff smoke contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("exports bulk adjust currency and trade lock", () => {
|
||||
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
expect(src).toContain("bulkAdjustCurrency");
|
||||
expect(src).toContain("setTradeLock");
|
||||
expect(src).toContain("tradeLockedUntil");
|
||||
expect(src).toContain("UsersSettings");
|
||||
expect(src).toContain("rcon.setTradeLock");
|
||||
it("exports bulk adjust currency and keeps trade-lock DB/RCON behavior", () => {
|
||||
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
const service = readFileSync(
|
||||
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain("bulkAdjustCurrency");
|
||||
expect(wrapper).toContain("setTradeLock");
|
||||
expect(service).toContain("tradeLockedUntil");
|
||||
expect(service).toContain("UsersSettings");
|
||||
expect(service).toContain("rcon.setTradeLock");
|
||||
});
|
||||
|
||||
it("deletes photos via Drizzle with local file purge helper", () => {
|
||||
|
||||
Reference in new issue
Block a user