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.
|
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.
|
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 { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
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;
|
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
|
||||||
|
|
||||||
await peopleMutationService.execute(
|
await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.USERS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"application.decide",
|
"application.decide",
|
||||||
{ applicationId, decision: "dismiss" },
|
{ applicationId, decision: "dismiss" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { disbandGuild } from "./admin-guilds";
|
|||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(staff, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
staff,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
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;
|
if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
|
||||||
|
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.USERS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"guild.disband",
|
"guild.disband",
|
||||||
{ guildId },
|
{ guildId },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(staff, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
staff,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
@@ -37,11 +37,7 @@ async function run(
|
|||||||
const id = "id" in input ? input.id : undefined;
|
const id = "id" in input ? input.id : undefined;
|
||||||
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
|
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.SETTINGS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"ip.action",
|
"ip.action",
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { createTeam, deleteTeam } from "./admin-teams";
|
|||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(staff, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
staff,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
@@ -20,11 +20,7 @@ export async function createTeam(formData: FormData): Promise<void> {
|
|||||||
const rankName = text(formData, "rankName");
|
const rankName = text(formData, "rankName");
|
||||||
if (!rankName) return;
|
if (!rankName) return;
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.USERS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"team.change",
|
"team.change",
|
||||||
{
|
{
|
||||||
action: "create",
|
action: "create",
|
||||||
@@ -44,11 +40,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
|
|||||||
const teamId = Number(formData.get("id"));
|
const teamId = Number(formData.get("id"));
|
||||||
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
|
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.USERS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"team.change",
|
"team.change",
|
||||||
{ action: "delete", teamId },
|
{ action: "delete", teamId },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import { saveVpn } from "./admin-vpn";
|
|||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(staff, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
staff,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
@@ -20,11 +20,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
|
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.SETTINGS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"vpn.configure",
|
"vpn.configure",
|
||||||
{
|
{
|
||||||
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
|
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
@@ -24,11 +24,7 @@ export async function addWord(input: {
|
|||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
if (!word) return actionError("Word is required");
|
if (!word) return actionError("Word is required");
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.WORDFILTER_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"word-filter.update",
|
"word-filter.update",
|
||||||
{ action: "add", word },
|
{ action: "add", word },
|
||||||
);
|
);
|
||||||
@@ -44,11 +40,7 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
|||||||
if (!Number.isSafeInteger(id) || id <= 0)
|
if (!Number.isSafeInteger(id) || id <= 0)
|
||||||
return actionError("Missing word id");
|
return actionError("Missing word id");
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.WORDFILTER_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
"word-filter.update",
|
"word-filter.update",
|
||||||
{ action: "delete", id },
|
{ action: "delete", id },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(staff, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
staff,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(staff, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
staff,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
@@ -88,7 +86,7 @@ describe("legacy bulk user wrappers", () => {
|
|||||||
data: { userId: 9, untilUnix: 1234 },
|
data: { userId: 9, untilUnix: 1234 },
|
||||||
});
|
});
|
||||||
expect(execute).toHaveBeenLastCalledWith(
|
expect(execute).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ permission: "admin.users.edit" }),
|
expect.objectContaining({ expectedActorId: 1 }),
|
||||||
"user.trade-lock",
|
"user.trade-lock",
|
||||||
{ userId: 9, untilUnix: 1234 },
|
{ userId: 9, untilUnix: 1234 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
@@ -27,11 +27,7 @@ async function executeLegacy(
|
|||||||
input: unknown,
|
input: unknown,
|
||||||
) {
|
) {
|
||||||
return peopleMutationService.execute(
|
return peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staff,
|
|
||||||
PERMS.USERS_EDIT,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
operation,
|
operation,
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
@@ -259,6 +255,14 @@ export async function setTradeLock({
|
|||||||
userId,
|
userId,
|
||||||
untilUnix: until,
|
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 } };
|
return { ok: true, data: { userId, untilUnix: until } };
|
||||||
}
|
}
|
||||||
@@ -8,13 +8,11 @@ const { execute, staff } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(actor, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
actor,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
@@ -98,12 +96,13 @@ describe("legacy user safe-action wrappers", () => {
|
|||||||
}>
|
}>
|
||||||
)({ userId: 7 });
|
)({ userId: 7 });
|
||||||
|
|
||||||
expect(
|
expect(execute.mock.calls.map((call) => call[1])).toEqual([
|
||||||
execute.mock.calls.map((call) => [call[0].permission, call[1]]),
|
"user.update",
|
||||||
).toEqual([
|
"user.ban",
|
||||||
["admin.users.edit", "user.update"],
|
"user.reset-password",
|
||||||
["admin.users.ban", "user.ban"],
|
]);
|
||||||
["admin.users.reset_password", "user.reset-password"],
|
expect(execute.mock.calls.map((call) => call[0].expectedActorId)).toEqual([
|
||||||
|
1, 1, 1,
|
||||||
]);
|
]);
|
||||||
expect(reset.data.newPassword).toBe("temporary-password");
|
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 () => {
|
it("keeps tolerant application dismissal and /admin revalidation", async () => {
|
||||||
await dismissApplication(form({ id: "9" }));
|
await dismissApplication(form({ id: "9" }));
|
||||||
expect(execute).toHaveBeenCalledWith(
|
expect(execute).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ permission: "admin.users.edit" }),
|
expect.objectContaining({ expectedActorId: 1 }),
|
||||||
"application.decide",
|
"application.decide",
|
||||||
{ applicationId: 9, decision: "dismiss" },
|
{ applicationId: 9, decision: "dismiss" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ const { execute, staff } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||||
createLegacyPeopleMutationContext: vi.fn(
|
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||||
(actor, permission, correlationId) => ({
|
expectedActorId: staff.id,
|
||||||
actor,
|
correlationId,
|
||||||
permission,
|
legacy: true,
|
||||||
correlationId,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
peopleMutationService: { execute },
|
peopleMutationService: { execute },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
|||||||
+11
-21
@@ -3,7 +3,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createLegacyPeopleMutationContext,
|
createPeopleMutationInvocation,
|
||||||
type PeopleMutationOperation,
|
type PeopleMutationOperation,
|
||||||
peopleMutationService,
|
peopleMutationService,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
@@ -116,16 +116,11 @@ const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
|
|||||||
|
|
||||||
async function executeLegacy(
|
async function executeLegacy(
|
||||||
ctx: { session: { user: { id: number; username: string; rank: number } } },
|
ctx: { session: { user: { id: number; username: string; rank: number } } },
|
||||||
permission: string,
|
|
||||||
operation: PeopleMutationOperation,
|
operation: PeopleMutationOperation,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
) {
|
) {
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
createLegacyPeopleMutationContext(
|
createPeopleMutationInvocation(ctx.session.user, createCorrelationId()),
|
||||||
ctx.session.user,
|
|
||||||
permission,
|
|
||||||
createCorrelationId(),
|
|
||||||
),
|
|
||||||
operation,
|
operation,
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
@@ -148,7 +143,7 @@ export const updateUser = adminAction(
|
|||||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id: userId, ...fields } = ctx.data;
|
const { id: userId, ...fields } = ctx.data;
|
||||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", {
|
await executeLegacy(ctx, "user.update", {
|
||||||
userId,
|
userId,
|
||||||
fields,
|
fields,
|
||||||
});
|
});
|
||||||
@@ -159,7 +154,7 @@ export const updateUser = adminAction(
|
|||||||
export const banUser = adminAction(
|
export const banUser = adminAction(
|
||||||
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
|
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data);
|
await executeLegacy(ctx, "user.ban", ctx.data);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -169,7 +164,7 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
|||||||
export const unbanUser = adminAction(
|
export const unbanUser = adminAction(
|
||||||
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
|
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data);
|
await executeLegacy(ctx, "user.unban", ctx.data);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -177,12 +172,7 @@ export const unbanUser = adminAction(
|
|||||||
export const resetPassword = adminAction(
|
export const resetPassword = adminAction(
|
||||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
|
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const snapshot = await executeLegacy(
|
const snapshot = await executeLegacy(ctx, "user.reset-password", ctx.data);
|
||||||
ctx,
|
|
||||||
PERMS.USERS_RESET_PASSWORD,
|
|
||||||
"user.reset-password",
|
|
||||||
ctx.data,
|
|
||||||
);
|
|
||||||
return actionOk({
|
return actionOk({
|
||||||
newPassword: String(snapshot.output?.newPassword ?? ""),
|
newPassword: String(snapshot.output?.newPassword ?? ""),
|
||||||
});
|
});
|
||||||
@@ -192,7 +182,7 @@ export const resetPassword = adminAction(
|
|||||||
export const disconnectUser = adminAction(
|
export const disconnectUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data);
|
await executeLegacy(ctx, "user.disconnect", ctx.data);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -203,7 +193,7 @@ const alertUserSchema = userIdSchema.extend({
|
|||||||
export const alertUser = adminAction(
|
export const alertUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data);
|
await executeLegacy(ctx, "user.alert", ctx.data);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -214,7 +204,7 @@ const muteSchema = userIdSchema.extend({
|
|||||||
export const muteUser = adminAction(
|
export const muteUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data);
|
await executeLegacy(ctx, "user.mute", ctx.data);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -222,7 +212,7 @@ export const muteUser = adminAction(
|
|||||||
export const unmuteUser = adminAction(
|
export const unmuteUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data);
|
await executeLegacy(ctx, "user.unmute", ctx.data);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -233,7 +223,7 @@ const sendCreditsSchema = userIdSchema.extend({
|
|||||||
export const sendCredits = adminAction(
|
export const sendCredits = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data);
|
await executeLegacy(ctx, "user.send-currency", ctx.data);
|
||||||
return actionOk();
|
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({
|
export default async function HousekeepingPreviewRoutePage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ domain: string; segments?: readonly string[] }>;
|
params: Promise<{ domain: string; segments?: readonly string[] }>;
|
||||||
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const { domain, segments = [] } = await params;
|
const { domain, segments = [] } = await params;
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
@@ -39,5 +41,9 @@ export default async function HousekeepingPreviewRoutePage({
|
|||||||
notFound();
|
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) =>
|
execute: (context, input) =>
|
||||||
service.execute(
|
service.execute(
|
||||||
{
|
{
|
||||||
capability: context.capability,
|
|
||||||
correlationId: context.correlationId,
|
correlationId: context.correlationId,
|
||||||
|
expectedActorId: context.capability.actor.id,
|
||||||
},
|
},
|
||||||
options.operation,
|
options.operation,
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -165,12 +165,11 @@ describe("People user commands", () => {
|
|||||||
describe("People mutation service boundary", () => {
|
describe("People mutation service boundary", () => {
|
||||||
it("fails closed before the adapter when the exact capability is absent", async () => {
|
it("fails closed before the adapter when the exact capability is absent", async () => {
|
||||||
const execute = vi.fn(async () => ({ before: null, after: null }));
|
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(
|
const result = await service.execute(
|
||||||
{
|
{ expectedActorId: 42, correlationId: "denied-people" },
|
||||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
||||||
correlationId: "denied-people",
|
|
||||||
},
|
|
||||||
"user.ban",
|
"user.ban",
|
||||||
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
|
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
|
||||||
);
|
);
|
||||||
@@ -190,11 +189,11 @@ describe("People mutation service boundary", () => {
|
|||||||
after: { rank: 3 },
|
after: { rank: 3 },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const success = await createPeopleMutationService(snapshotAdapter).execute(
|
const success = await createPeopleMutationService(
|
||||||
{
|
snapshotAdapter,
|
||||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
async () => capabilityContext([PERMS.USERS_EDIT]),
|
||||||
correlationId: "snapshot-people",
|
).execute(
|
||||||
},
|
{ expectedActorId: 42, correlationId: "snapshot-people" },
|
||||||
"user.update",
|
"user.update",
|
||||||
{ userId: 7, fields: { rank: 3 } },
|
{ userId: 7, fields: { rank: 3 } },
|
||||||
);
|
);
|
||||||
@@ -208,11 +207,11 @@ describe("People mutation service boundary", () => {
|
|||||||
throw new Error("database password=secret");
|
throw new Error("database password=secret");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const failure = await createPeopleMutationService(failureAdapter).execute(
|
const failure = await createPeopleMutationService(
|
||||||
{
|
failureAdapter,
|
||||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
async () => capabilityContext([PERMS.USERS_EDIT]),
|
||||||
correlationId: "failed-people",
|
).execute(
|
||||||
},
|
{ expectedActorId: 42, correlationId: "failed-people" },
|
||||||
"user.update",
|
"user.update",
|
||||||
{ userId: 7, fields: { motto: "Ready" } },
|
{ userId: 7, fields: { motto: "Ready" } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ function userCommand<I>(
|
|||||||
execute: (context, input) =>
|
execute: (context, input) =>
|
||||||
service.execute(
|
service.execute(
|
||||||
{
|
{
|
||||||
capability: context.capability,
|
|
||||||
correlationId: context.correlationId,
|
correlationId: context.correlationId,
|
||||||
|
expectedActorId: context.capability.actor.id,
|
||||||
},
|
},
|
||||||
options.operation,
|
options.operation,
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -10,58 +10,71 @@ import {
|
|||||||
type PeopleCommunityQueryData,
|
type PeopleCommunityQueryData,
|
||||||
peopleCommunityQuery,
|
peopleCommunityQuery,
|
||||||
} from "../queries/community";
|
} from "../queries/community";
|
||||||
import { PeoplePageFrame } from "./page-state";
|
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||||
|
import { PeopleCommandForm } from "./people-command-form";
|
||||||
|
|
||||||
interface PeopleCommunityPageProps {
|
interface PeopleCommunityPageProps {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly result?: HousekeepingResult<PeopleCommunityQueryData>;
|
readonly result?: HousekeepingResult<PeopleCommunityQueryData>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function empty(data: PeopleCommunityQueryData) {
|
function empty(data: PeopleCommunityQueryData) {
|
||||||
return data.kind !== "guild" && data.page.items.length === 0;
|
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({
|
export function PeopleCommunityPage({
|
||||||
context,
|
context,
|
||||||
result,
|
result,
|
||||||
partialDependencies,
|
|
||||||
}: PeopleCommunityPageProps) {
|
}: PeopleCommunityPageProps) {
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
title="Community"
|
title="Community"
|
||||||
description="Review online users and guild ownership."
|
description="Review online users and guild ownership."
|
||||||
result={result}
|
result={result}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
isEmpty={empty}
|
isEmpty={empty}
|
||||||
>
|
>
|
||||||
{(data) => {
|
{(data) => {
|
||||||
if (data.kind === "online")
|
if (data.kind === "online")
|
||||||
return (
|
return (
|
||||||
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
<div className="space-y-3">
|
||||||
{data.page.items.map((user) => (
|
<SearchForm />
|
||||||
<li key={user.id}>
|
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||||
<a href={user.href}>{user.username}</a> - {user.motto}
|
{data.page.items.map((user) => (
|
||||||
</li>
|
<li key={user.id}>
|
||||||
))}
|
<a href={user.href}>{user.username}</a> - {user.motto}
|
||||||
</ul>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
if (data.kind === "guilds")
|
if (data.kind === "guilds")
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<div className="space-y-3">
|
||||||
{data.page.items.map((guild) => (
|
<SearchForm />
|
||||||
<li
|
<ul className="space-y-2">
|
||||||
key={guild.id}
|
{data.page.items.map((guild) => (
|
||||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
<li
|
||||||
>
|
key={guild.id}
|
||||||
<a href={guild.href}>{guild.name}</a>
|
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||||
<p>{guild.memberCount} members</p>
|
>
|
||||||
</li>
|
<a href={guild.href}>{guild.name}</a>
|
||||||
))}
|
<p>{guild.memberCount} members</p>
|
||||||
</ul>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
return (
|
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>
|
<h2>{data.guild.name}</h2>
|
||||||
<p>{data.guild.description}</p>
|
<p>{data.guild.description}</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -72,9 +85,12 @@ export function PeopleCommunityPage({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{context.has(PERMS.USERS_EDIT) ? (
|
{context.has(PERMS.USERS_EDIT) ? (
|
||||||
<span data-housekeeping-command="people.guild.disband">
|
<PeopleCommandForm
|
||||||
Disband guild
|
commandId="people.guild.disband"
|
||||||
</span>
|
buttonLabel="Disband guild"
|
||||||
|
input={{ guildId: data.guild.id }}
|
||||||
|
requiresReason
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -101,7 +117,10 @@ export async function renderPeopleCommunityPage(input: HousekeepingPageInput) {
|
|||||||
})()
|
})()
|
||||||
: routeId === "people.community.online" ||
|
: routeId === "people.community.online" ||
|
||||||
routeId === "people.community.guilds"
|
routeId === "people.community.guilds"
|
||||||
? peopleCommunityQuery.run(input.context, { routeId, list: {} })
|
? peopleCommunityQuery.run(input.context, {
|
||||||
|
routeId,
|
||||||
|
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||||
|
})
|
||||||
: Promise.resolve(
|
: Promise.resolve(
|
||||||
fail(
|
fail(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
|
|||||||
@@ -4,53 +4,60 @@ import type {
|
|||||||
} from "../../../foundation/contracts";
|
} from "../../../foundation/contracts";
|
||||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||||
import { PeoplePageFrame } from "./page-state";
|
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||||
|
|
||||||
interface PeopleMultiAccountsPageProps {
|
interface PeopleMultiAccountsPageProps {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeopleMultiAccountsPage({
|
export function PeopleMultiAccountsPage({
|
||||||
context: _context,
|
context: _context,
|
||||||
result,
|
result,
|
||||||
partialDependencies,
|
|
||||||
}: PeopleMultiAccountsPageProps) {
|
}: PeopleMultiAccountsPageProps) {
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
title="Multi-account clusters"
|
title="Multi-account clusters"
|
||||||
description="Review bounded account clusters grouped by current IP."
|
description="Review bounded account clusters grouped by current IP."
|
||||||
result={result}
|
result={result}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
isEmpty={(data) =>
|
isEmpty={(data) =>
|
||||||
data.kind === "multi-accounts" && data.page.items.length === 0
|
data.kind === "multi-accounts" && data.page.items.length === 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(data) =>
|
{(data) =>
|
||||||
data.kind === "multi-accounts" ? (
|
data.kind === "multi-accounts" ? (
|
||||||
<ul className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.page.items.map((cluster) => (
|
<form method="get" className="flex gap-2">
|
||||||
<li
|
<input
|
||||||
key={cluster.key}
|
name="search"
|
||||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
maxLength={100}
|
||||||
>
|
aria-label="Search IP clusters"
|
||||||
<h2 className="font-medium text-[var(--admin-text)]">
|
/>
|
||||||
{cluster.key}
|
<button type="submit">Search</button>
|
||||||
</h2>
|
</form>
|
||||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
<ul className="space-y-3">
|
||||||
{cluster.accountCount} accounts
|
{data.page.items.map((cluster) => (
|
||||||
</p>
|
<li
|
||||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
|
key={cluster.key}
|
||||||
{cluster.accounts.map((account) => (
|
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||||
<li key={account.id}>
|
>
|
||||||
<a href={account.href}>{account.username}</a>
|
<h2 className="font-medium text-[var(--admin-text)]">
|
||||||
</li>
|
{cluster.key}
|
||||||
))}
|
</h2>
|
||||||
</ul>
|
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||||
</li>
|
{cluster.accountCount} accounts
|
||||||
))}
|
</p>
|
||||||
</ul>
|
<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
|
) : null
|
||||||
}
|
}
|
||||||
</PeoplePageFrame>
|
</PeoplePageFrame>
|
||||||
@@ -62,7 +69,7 @@ export async function renderPeopleMultiAccountsPage(
|
|||||||
) {
|
) {
|
||||||
const result = await peopleUsersQuery.run(input.context, {
|
const result = await peopleUsersQuery.run(input.context, {
|
||||||
routeId: "people.users.multi-accounts",
|
routeId: "people.users.multi-accounts",
|
||||||
list: {},
|
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||||
});
|
});
|
||||||
return <PeopleMultiAccountsPage context={input.context} result={result} />;
|
return <PeopleMultiAccountsPage context={input.context} result={result} />;
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,60 @@ import type { ReactNode } from "react";
|
|||||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||||
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
||||||
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
|
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> {
|
interface PeoplePageFrameProps<T> {
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly result?: HousekeepingResult<T>;
|
readonly result?: HousekeepingResult<T>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
readonly isEmpty: (data: T) => boolean;
|
readonly isEmpty: (data: T) => boolean;
|
||||||
readonly children: (data: T) => ReactNode;
|
readonly children: (data: T) => ReactNode;
|
||||||
}
|
}
|
||||||
@@ -32,7 +80,6 @@ export function PeoplePageFrame<T>({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
result,
|
result,
|
||||||
partialDependencies = [],
|
|
||||||
isEmpty,
|
isEmpty,
|
||||||
children,
|
children,
|
||||||
}: PeoplePageFrameProps<T>) {
|
}: PeoplePageFrameProps<T>) {
|
||||||
@@ -76,20 +123,7 @@ export function PeoplePageFrame<T>({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<div
|
<div data-housekeeping-state="ready" className="space-y-4">
|
||||||
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}
|
|
||||||
{children(result.data)}
|
{children(result.data)}
|
||||||
</div>
|
</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 { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
|
||||||
|
vi.mock("@/actions/housekeeping-command", () => ({
|
||||||
|
executeHousekeepingCommand: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fail,
|
fail,
|
||||||
type HousekeepingCapabilityContext,
|
type HousekeepingCapabilityContext,
|
||||||
@@ -19,6 +25,7 @@ import {
|
|||||||
} from "../route-handlers";
|
} from "../route-handlers";
|
||||||
import { PeopleCommunityPage } from "./community";
|
import { PeopleCommunityPage } from "./community";
|
||||||
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
||||||
|
import { parsePeopleListInput } from "./page-state";
|
||||||
import { PeopleStaffPage } from "./staff";
|
import { PeopleStaffPage } from "./staff";
|
||||||
import { PeopleUserDetailPage } from "./user-detail";
|
import { PeopleUserDetailPage } from "./user-detail";
|
||||||
import { PeopleUserEditPage } from "./user-edit";
|
import { PeopleUserEditPage } from "./user-edit";
|
||||||
@@ -77,12 +84,11 @@ type PageCase = {
|
|||||||
const cases: readonly PageCase[] = [
|
const cases: readonly PageCase[] = [
|
||||||
{
|
{
|
||||||
name: "users",
|
name: "users",
|
||||||
render: (result, partialDependencies) =>
|
render: (result) =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<PeopleUsersPage
|
<PeopleUsersPage
|
||||||
context={readContext}
|
context={readContext}
|
||||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
empty: {
|
empty: {
|
||||||
@@ -96,12 +102,11 @@ const cases: readonly PageCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "user-detail",
|
name: "user-detail",
|
||||||
render: (result, partialDependencies) =>
|
render: (result) =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<PeopleUserDetailPage
|
<PeopleUserDetailPage
|
||||||
context={readContext}
|
context={readContext}
|
||||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
empty: { kind: "user", user: { ...detail, sanctions: [] } },
|
empty: { kind: "user", user: { ...detail, sanctions: [] } },
|
||||||
@@ -124,12 +129,11 @@ const cases: readonly PageCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "user-edit",
|
name: "user-edit",
|
||||||
render: (result, partialDependencies) =>
|
render: (result) =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<PeopleUserEditPage
|
<PeopleUserEditPage
|
||||||
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
|
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
|
||||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
|
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
|
||||||
@@ -137,12 +141,11 @@ const cases: readonly PageCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi-accounts",
|
name: "multi-accounts",
|
||||||
render: (result, partialDependencies) =>
|
render: (result) =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<PeopleMultiAccountsPage
|
<PeopleMultiAccountsPage
|
||||||
context={readContext}
|
context={readContext}
|
||||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
empty: {
|
empty: {
|
||||||
@@ -161,12 +164,11 @@ const cases: readonly PageCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "community",
|
name: "community",
|
||||||
render: (result, partialDependencies) =>
|
render: (result) =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<PeopleCommunityPage
|
<PeopleCommunityPage
|
||||||
context={readContext}
|
context={readContext}
|
||||||
result={result as HousekeepingResult<PeopleCommunityQueryData>}
|
result={result as HousekeepingResult<PeopleCommunityQueryData>}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
empty: {
|
empty: {
|
||||||
@@ -197,12 +199,11 @@ const cases: readonly PageCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "staff",
|
name: "staff",
|
||||||
render: (result, partialDependencies) =>
|
render: (result) =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<PeopleStaffPage
|
<PeopleStaffPage
|
||||||
context={readContext}
|
context={readContext}
|
||||||
result={result as HousekeepingResult<PeopleStaffQueryData>}
|
result={result as HousekeepingResult<PeopleStaffQueryData>}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
empty: {
|
empty: {
|
||||||
@@ -231,7 +232,7 @@ const cases: readonly PageCase[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
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()).toContain('data-housekeeping-state="loading"');
|
||||||
expect(
|
expect(
|
||||||
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
|
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
|
||||||
@@ -250,9 +251,6 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
|||||||
? (empty as HousekeepingResult<unknown>)
|
? (empty as HousekeepingResult<unknown>)
|
||||||
: ok(empty, correlationId);
|
: ok(empty, correlationId);
|
||||||
expect(render(emptyResult)).toContain('data-housekeeping-state="empty"');
|
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(
|
expect(render(ok(ready, correlationId))).toContain(
|
||||||
'data-housekeeping-state="ready"',
|
'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('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");
|
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";
|
} from "../../../foundation/contracts";
|
||||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff";
|
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 {
|
interface PeopleStaffPageProps {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly result?: HousekeepingResult<PeopleStaffQueryData>;
|
readonly result?: HousekeepingResult<PeopleStaffQueryData>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeopleStaffPage({
|
export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
|
||||||
context,
|
|
||||||
result,
|
|
||||||
partialDependencies,
|
|
||||||
}: PeopleStaffPageProps) {
|
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
title="Staff"
|
title="Staff"
|
||||||
description="Review staff applications and team definitions."
|
description="Review staff applications and team definitions."
|
||||||
result={result}
|
result={result}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
isEmpty={(data) => data.page.items.length === 0}
|
isEmpty={(data) => data.page.items.length === 0}
|
||||||
>
|
>
|
||||||
{(data) =>
|
{(data) => (
|
||||||
data.kind === "applications" ? (
|
<div className="space-y-3">
|
||||||
<ul className="space-y-2">
|
<form method="get" className="flex gap-2">
|
||||||
{data.page.items.map((application) => (
|
<input
|
||||||
<li
|
name="search"
|
||||||
key={application.id}
|
maxLength={100}
|
||||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
aria-label="Search staff workflow"
|
||||||
>
|
/>
|
||||||
<h2>{application.username ?? `User #${application.userId}`}</h2>
|
<button type="submit">Search</button>
|
||||||
<p>{application.content}</p>
|
</form>
|
||||||
{context.has(PERMS.USERS_EDIT) ? (
|
{data.kind === "applications" ? (
|
||||||
<span data-housekeeping-command="people.application.decide">
|
<ul className="space-y-2">
|
||||||
Dismiss application
|
{data.page.items.map((application) => (
|
||||||
</span>
|
<li
|
||||||
) : null}
|
key={application.id}
|
||||||
</li>
|
className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||||
))}
|
>
|
||||||
</ul>
|
<h2>
|
||||||
) : data.kind === "teams" ? (
|
{application.username ?? `User #${application.userId}`}
|
||||||
<ul className="space-y-2">
|
</h2>
|
||||||
{data.page.items.map((team) => (
|
<p>{application.content}</p>
|
||||||
<li
|
{context.has(PERMS.USERS_EDIT) ? (
|
||||||
key={team.id}
|
<PeopleCommandForm
|
||||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
commandId="people.application.decide"
|
||||||
>
|
buttonLabel="Dismiss application"
|
||||||
<h2>{team.name}</h2>
|
input={{
|
||||||
<p>{team.jobDescription ?? "No job description"}</p>
|
applicationId: application.id,
|
||||||
{context.has(PERMS.USERS_EDIT) ? (
|
decision: "dismiss",
|
||||||
<span data-housekeeping-command="people.team.change">
|
}}
|
||||||
Team controls
|
requiresReason
|
||||||
</span>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null
|
) : 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>
|
</PeoplePageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,7 +132,10 @@ export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
|
|||||||
const routeId = input.match.routeId;
|
const routeId = input.match.routeId;
|
||||||
const result =
|
const result =
|
||||||
routeId === "people.staff.applications" || routeId === "people.staff.teams"
|
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(
|
: fail(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
"errors.housekeeping.notFound",
|
"errors.housekeeping.notFound",
|
||||||
|
|||||||
@@ -8,24 +8,22 @@ import {
|
|||||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||||
import { PeoplePageFrame } from "./page-state";
|
import { PeoplePageFrame } from "./page-state";
|
||||||
|
import { PeopleCommandForm } from "./people-command-form";
|
||||||
|
|
||||||
interface PeopleUserDetailPageProps {
|
interface PeopleUserDetailPageProps {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeopleUserDetailPage({
|
export function PeopleUserDetailPage({
|
||||||
context,
|
context,
|
||||||
result,
|
result,
|
||||||
partialDependencies,
|
|
||||||
}: PeopleUserDetailPageProps) {
|
}: PeopleUserDetailPageProps) {
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
title="User detail"
|
title="User detail"
|
||||||
description="Review account state, permissions, and sanctions."
|
description="Review account state, permissions, and sanctions."
|
||||||
result={result}
|
result={result}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
isEmpty={() => false}
|
isEmpty={() => false}
|
||||||
>
|
>
|
||||||
{(data) =>
|
{(data) =>
|
||||||
@@ -37,12 +35,7 @@ export function PeopleUserDetailPage({
|
|||||||
{data.user.username}
|
{data.user.username}
|
||||||
</h2>
|
</h2>
|
||||||
{context.has(PERMS.USERS_EDIT) ? (
|
{context.has(PERMS.USERS_EDIT) ? (
|
||||||
<a
|
<a href={`${data.user.href}/edit`}>Edit</a>
|
||||||
href={`${data.user.href}/edit`}
|
|
||||||
data-housekeeping-command="people.user.update"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
<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}
|
) : null}
|
||||||
</dl>
|
</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>
|
||||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||||
<h2 className="font-medium text-[var(--admin-text)]">
|
<h2 className="font-medium text-[var(--admin-text)]">
|
||||||
@@ -102,6 +80,126 @@ export function PeopleUserDetailPage({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,80 +7,74 @@ import {
|
|||||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||||
import { PeoplePageFrame } from "./page-state";
|
import { PeoplePageFrame } from "./page-state";
|
||||||
|
import {
|
||||||
|
type PeopleCommandField,
|
||||||
|
PeopleCommandForm,
|
||||||
|
} from "./people-command-form";
|
||||||
|
|
||||||
interface PeopleUserEditPageProps {
|
interface PeopleUserEditPageProps {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeopleUserEditPage({
|
export function PeopleUserEditPage({ result }: PeopleUserEditPageProps) {
|
||||||
context: _context,
|
|
||||||
result,
|
|
||||||
partialDependencies,
|
|
||||||
}: PeopleUserEditPageProps) {
|
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
title="Edit user"
|
title="Edit user"
|
||||||
description="Update bounded account fields through an audited command."
|
description="Update bounded account fields through an audited command."
|
||||||
result={result}
|
result={result}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
isEmpty={() => false}
|
isEmpty={() => false}
|
||||||
>
|
>
|
||||||
{(data) =>
|
{(data) => {
|
||||||
data.kind === "user" ? (
|
if (data.kind !== "user") return null;
|
||||||
<form
|
const fields: PeopleCommandField[] = [
|
||||||
data-housekeeping-command="people.user.update"
|
{
|
||||||
className="space-y-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
name: "username",
|
||||||
>
|
label: "Username",
|
||||||
<input type="hidden" name="userId" value={data.user.id} />
|
type: "text",
|
||||||
<label className="block text-sm">
|
defaultValue: data.user.username,
|
||||||
Username
|
maxLength: 20,
|
||||||
<input
|
required: true,
|
||||||
name="username"
|
},
|
||||||
defaultValue={data.user.username}
|
...(data.user.mail !== null
|
||||||
maxLength={20}
|
? [
|
||||||
className="mt-1 block w-full"
|
{
|
||||||
/>
|
name: "mail",
|
||||||
</label>
|
label: "Email",
|
||||||
{data.user.mail !== null ? (
|
type: "text",
|
||||||
<label className="block text-sm">
|
defaultValue: data.user.mail,
|
||||||
Email
|
maxLength: 255,
|
||||||
<input
|
} as const,
|
||||||
name="mail"
|
]
|
||||||
type="email"
|
: []),
|
||||||
defaultValue={data.user.mail}
|
{
|
||||||
className="mt-1 block w-full"
|
name: "motto",
|
||||||
/>
|
label: "Motto",
|
||||||
</label>
|
type: "text",
|
||||||
) : null}
|
defaultValue: data.user.motto,
|
||||||
<label className="block text-sm">
|
maxLength: 127,
|
||||||
Motto
|
},
|
||||||
<input
|
{
|
||||||
name="motto"
|
name: "rank",
|
||||||
defaultValue={data.user.motto}
|
label: "Rank",
|
||||||
maxLength={127}
|
type: "select",
|
||||||
className="mt-1 block w-full"
|
defaultValue: data.user.rank,
|
||||||
/>
|
options: data.user.permission.ranks.map((rank) => ({
|
||||||
</label>
|
value: rank.id,
|
||||||
<label className="block text-sm">
|
label: rank.name,
|
||||||
Rank
|
})),
|
||||||
<select
|
},
|
||||||
name="rank"
|
];
|
||||||
defaultValue={data.user.rank}
|
return (
|
||||||
className="mt-1 block w-full"
|
<PeopleCommandForm
|
||||||
>
|
commandId="people.user.update"
|
||||||
{data.user.permission.ranks.map((rank) => (
|
buttonLabel="Save user"
|
||||||
<option key={rank.id} value={rank.id}>
|
input={{ userId: data.user.id }}
|
||||||
{rank.name}
|
fields={fields}
|
||||||
</option>
|
nestFields
|
||||||
))}
|
/>
|
||||||
</select>
|
);
|
||||||
</label>
|
}}
|
||||||
<button type="submit">Save user</button>
|
|
||||||
</form>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
</PeoplePageFrame>
|
</PeoplePageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,72 +5,143 @@ import type {
|
|||||||
} from "../../../foundation/contracts";
|
} from "../../../foundation/contracts";
|
||||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
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 {
|
interface PeopleUsersPageProps {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||||
readonly partialDependencies?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeopleUsersPage({
|
const userIdsField = {
|
||||||
context,
|
name: "userIds",
|
||||||
result,
|
label: "User IDs (comma or space separated)",
|
||||||
partialDependencies,
|
type: "number-list" as const,
|
||||||
}: PeopleUsersPageProps) {
|
required: true,
|
||||||
|
maxLength: 1200,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PeopleUsersPage({ context, result }: PeopleUsersPageProps) {
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
title="Users"
|
title="Users"
|
||||||
description="Search accounts and open the canonical People workflow."
|
description="Search accounts and open the canonical People workflow."
|
||||||
result={result}
|
result={result}
|
||||||
partialDependencies={partialDependencies}
|
|
||||||
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
|
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
|
||||||
>
|
>
|
||||||
{(data) =>
|
{(data) =>
|
||||||
data.kind === "users" ? (
|
data.kind === "users" ? (
|
||||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
<form method="get" className="flex flex-wrap gap-2">
|
||||||
{data.page.total} matching users
|
<label>
|
||||||
</p>
|
Search users
|
||||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
<input name="search" maxLength={100} className="ml-2" />
|
||||||
{data.page.items.map((user) => (
|
</label>
|
||||||
<li
|
<input type="hidden" name="pageSize" value={data.page.pageSize} />
|
||||||
key={user.id}
|
<button type="submit">Search</button>
|
||||||
className="flex flex-wrap items-center gap-3 py-3 text-sm"
|
</form>
|
||||||
>
|
{context.has(PERMS.USERS_EDIT) ? (
|
||||||
<a
|
<section className="grid gap-3 lg:grid-cols-2">
|
||||||
className="font-medium text-[var(--admin-text)]"
|
<PeopleCommandForm
|
||||||
href={user.href}
|
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
|
<a
|
||||||
href={`${user.href}/edit`}
|
className="font-medium text-[var(--admin-text)]"
|
||||||
data-housekeeping-command="people.user.update"
|
href={user.href}
|
||||||
>
|
>
|
||||||
Edit
|
{user.username}
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
<span>Rank {user.rank}</span>
|
||||||
{context.has(PERMS.USERS_BAN) ? (
|
<span>{user.online ? "Online" : "Offline"}</span>
|
||||||
<span data-housekeeping-command="people.user.ban">
|
{user.mail !== null ? <span>{user.mail}</span> : null}
|
||||||
Ban controls available
|
{user.ipCurrent !== null ? (
|
||||||
</span>
|
<span>{user.ipCurrent}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
{context.has(PERMS.USERS_EDIT) ? (
|
||||||
))}
|
<a href={`${user.href}/edit`}>Edit</a>
|
||||||
</ul>
|
) : null}
|
||||||
</section>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
</PeoplePageFrame>
|
</PeoplePageFrame>
|
||||||
@@ -80,7 +151,7 @@ export function PeopleUsersPage({
|
|||||||
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
|
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
|
||||||
const result = await peopleUsersQuery.run(input.context, {
|
const result = await peopleUsersQuery.run(input.context, {
|
||||||
routeId: "people.users.list",
|
routeId: "people.users.list",
|
||||||
list: {},
|
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||||
});
|
});
|
||||||
return <PeopleUsersPage context={input.context} result={result} />;
|
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 { PERMS } from "@/lib/permission-slugs";
|
||||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
|
||||||
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({
|
const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted(
|
||||||
audit: vi.fn(),
|
() => ({
|
||||||
alertUser: vi.fn(),
|
audit: vi.fn(),
|
||||||
selectLimit: 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", () => ({ invalidateLoginCache: vi.fn() }));
|
||||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||||
vi.mock("@/lib/db", async (importOriginal) => {
|
vi.mock("@/lib/db", async (importOriginal) => {
|
||||||
@@ -62,6 +68,7 @@ const target = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
resolveServerContext.mockResolvedValue(capabilityContext([]));
|
||||||
selectLimit.mockResolvedValue([target]);
|
selectLimit.mockResolvedValue([target]);
|
||||||
alertUser.mockResolvedValue(true);
|
alertUser.mockResolvedValue(true);
|
||||||
audit.mockResolvedValue(undefined);
|
audit.mockResolvedValue(undefined);
|
||||||
@@ -70,7 +77,7 @@ beforeEach(() => {
|
|||||||
describe("People production mutation boundary", () => {
|
describe("People production mutation boundary", () => {
|
||||||
it("rechecks authorization before any production adapter work", async () => {
|
it("rechecks authorization before any production adapter work", async () => {
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
{ capability: capabilityContext([]), correlationId: "production-denied" },
|
{ expectedActorId: 42, correlationId: "production-denied" },
|
||||||
"user.alert",
|
"user.alert",
|
||||||
{ userId: 7, message: "Hello" },
|
{ 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 () => {
|
it("emits sanitized before and after evidence for a successful mutation", async () => {
|
||||||
|
resolveServerContext.mockResolvedValue(
|
||||||
|
capabilityContext([PERMS.USERS_EDIT]),
|
||||||
|
);
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
{
|
{ expectedActorId: 42, correlationId: "production-alert" },
|
||||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
||||||
correlationId: "production-alert",
|
|
||||||
},
|
|
||||||
"user.alert",
|
"user.alert",
|
||||||
{ userId: 7, message: "Hello" },
|
{ 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"));
|
audit.mockRejectedValue(new Error("audit database unavailable"));
|
||||||
const result = await peopleMutationService.execute(
|
const result = await peopleMutationService.execute(
|
||||||
{
|
{ expectedActorId: 42, correlationId: "production-audit-failure" },
|
||||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
|
||||||
correlationId: "production-audit-failure",
|
|
||||||
},
|
|
||||||
"user.alert",
|
"user.alert",
|
||||||
{ userId: 7, message: "Hello" },
|
{ userId: 7, message: "Hello" },
|
||||||
);
|
);
|
||||||
expect(alertUser).toHaveBeenCalled();
|
expect(alertUser).not.toHaveBeenCalled();
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
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 { PERMS } from "@/lib/permission-slugs";
|
||||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
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(),
|
audit: vi.fn(),
|
||||||
disconnectUser: vi.fn(),
|
disconnectUser: vi.fn(),
|
||||||
insertValues: vi.fn(),
|
insertValues: vi.fn(),
|
||||||
|
resolveServerContext: vi.fn(),
|
||||||
selectLimit: 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", () => ({ invalidateLoginCache: vi.fn() }));
|
||||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||||
vi.mock("@/lib/db", async (importOriginal) => {
|
vi.mock("@/lib/db", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
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 {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
db: {
|
db: {
|
||||||
@@ -22,7 +44,9 @@ vi.mock("@/lib/db", async (importOriginal) => {
|
|||||||
where: vi.fn(() => ({ limit: selectLimit })),
|
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")>()),
|
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
||||||
rcon: { disconnectUser },
|
rcon: { disconnectUser },
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||||
|
|
||||||
import { peopleMutationService } from "./mutations";
|
import { peopleMutationService } from "./mutations";
|
||||||
|
|
||||||
function capabilityContext(
|
function capabilityContext(): HousekeepingCapabilityContext {
|
||||||
granted: readonly string[],
|
|
||||||
): HousekeepingCapabilityContext {
|
|
||||||
const permissions = new Set(granted);
|
|
||||||
return {
|
return {
|
||||||
actor: { id: 42, username: "operator", rank: 6 },
|
actor: { id: 42, username: "operator", rank: 6 },
|
||||||
isSuperAdmin: false,
|
isSuperAdmin: false,
|
||||||
has: (slug) => permissions.has(slug),
|
has: (slug) => slug === PERMS.USERS_BAN,
|
||||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
hasAny: (...slugs) => slugs.includes(PERMS.USERS_BAN),
|
||||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_BAN),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
resolveServerContext.mockResolvedValue(capabilityContext());
|
||||||
selectLimit.mockResolvedValue([
|
selectLimit.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
username: "Alice",
|
username: "Alice",
|
||||||
mail: "[email protected]",
|
|
||||||
rank: 2,
|
rank: 2,
|
||||||
motto: "Ready",
|
motto: "Ready",
|
||||||
credits: 100,
|
credits: 100,
|
||||||
@@ -65,18 +87,16 @@ beforeEach(() => {
|
|||||||
online: "1",
|
online: "1",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
txSelectLimit.mockResolvedValue([]);
|
||||||
insertValues.mockResolvedValue([{}]);
|
insertValues.mockResolvedValue([{}]);
|
||||||
disconnectUser.mockResolvedValue(true);
|
disconnectUser.mockResolvedValue(true);
|
||||||
audit.mockResolvedValue(undefined);
|
audit.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("People production reason audit", () => {
|
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(
|
const result = await peopleMutationService.execute(
|
||||||
{
|
{ expectedActorId: 42, correlationId: "production-ban" },
|
||||||
capability: capabilityContext([PERMS.USERS_BAN]),
|
|
||||||
correlationId: "production-ban",
|
|
||||||
},
|
|
||||||
"user.ban",
|
"user.ban",
|
||||||
{
|
{
|
||||||
userId: 7,
|
userId: 7,
|
||||||
@@ -87,9 +107,11 @@ describe("People production reason audit", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: true });
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(audit).toHaveBeenCalledWith(
|
expect(audit).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: "people.user.ban",
|
action: "people.user.ban",
|
||||||
|
outcome: "intent",
|
||||||
before: expect.objectContaining({ id: 7, banned: false }),
|
before: expect.objectContaining({ id: 7, banned: false }),
|
||||||
after: expect.objectContaining({
|
after: expect.objectContaining({
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -97,6 +119,7 @@ describe("People production reason audit", () => {
|
|||||||
reason: "Repeated harassment",
|
reason: "Repeated harassment",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
|
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
|
||||||
expect(audit.mock.calls[0]?.[0]?.after).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",
|
"src/features/housekeeping/domains/people/pages/community.tsx",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||||
"src/features/housekeeping/domains/people/pages/page-state",
|
"src/features/housekeeping/domains/people/pages/page-state",
|
||||||
"src/features/housekeeping/domains/people/queries/community",
|
"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",
|
"src/features/housekeeping/domains/people/pages/multi-accounts.tsx",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||||
"src/features/housekeeping/domains/people/pages/page-state",
|
"src/features/housekeeping/domains/people/pages/page-state",
|
||||||
"src/features/housekeeping/domains/people/queries/users",
|
"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",
|
"src/features/housekeeping/domains/people/pages/staff.tsx",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||||
"src/features/housekeeping/domains/people/pages/page-state",
|
"src/features/housekeeping/domains/people/pages/page-state",
|
||||||
"src/features/housekeeping/domains/people/queries/staff",
|
"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",
|
"src/features/housekeeping/domains/people/pages/user-detail.tsx",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||||
"src/features/housekeeping/domains/people/pages/page-state",
|
"src/features/housekeeping/domains/people/pages/page-state",
|
||||||
"src/features/housekeeping/domains/people/queries/users",
|
"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",
|
"src/features/housekeeping/domains/people/pages/user-edit.tsx",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||||
"src/features/housekeeping/domains/people/pages/page-state",
|
"src/features/housekeeping/domains/people/pages/page-state",
|
||||||
"src/features/housekeeping/domains/people/queries/users",
|
"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",
|
"src/features/housekeeping/domains/people/pages/users.tsx",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||||
"src/features/housekeeping/domains/people/pages/page-state",
|
"src/features/housekeeping/domains/people/pages/page-state",
|
||||||
"src/features/housekeeping/domains/people/queries/users",
|
"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",
|
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||||
new Set([
|
new Set([
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
|||||||
export interface HousekeepingPageInput {
|
export interface HousekeepingPageInput {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
readonly match: HousekeepingRouteMatch;
|
readonly match: HousekeepingRouteMatch;
|
||||||
|
readonly searchParams?: Readonly<
|
||||||
|
Record<string, string | readonly string[] | undefined>
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HousekeepingRouteHandler {
|
export interface HousekeepingRouteHandler {
|
||||||
|
|||||||
@@ -136,13 +136,17 @@ describe("staff smoke contract", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports bulk adjust currency and trade lock", () => {
|
it("exports bulk adjust currency and keeps trade-lock DB/RCON behavior", () => {
|
||||||
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||||
expect(src).toContain("bulkAdjustCurrency");
|
const service = readFileSync(
|
||||||
expect(src).toContain("setTradeLock");
|
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||||
expect(src).toContain("tradeLockedUntil");
|
"utf8",
|
||||||
expect(src).toContain("UsersSettings");
|
);
|
||||||
expect(src).toContain("rcon.setTradeLock");
|
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", () => {
|
it("deletes photos via Drizzle with local file purge helper", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user