Files
EpicNext-Cms/src/actions/user-settings.test.ts
T
openhands 6cc45d7413
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat: harden atoms-nexst against review findings (37 items)
Second review pass covering security, performance, admin tooling and the
public/room flows. All HIGH and MEDIUM findings from the audit are resolved;
nothing in this commit changes the visible feature set.

Authentication & session security
- CSP is now set on the request headers in the proxy, which is what Next.js
  uses to derive the render nonce, so the nonce is effective.
- 2FA: an already-enabled user cannot re-enroll, the setup endpoint is
  rate-limited per account, and confirmed codes are persisted so the second
  secret no longer silently never applies.
- Password reset revokes the ticket, authTicket and all personal access
  tokens, and bumps the token version so existing sessions die. The same
  revocation is now wired into the staff-side password reset.
- /reset and /verify return a stable error code instead of raw text; the
  mail lookups are ordered by id so duplicates cannot vary between runs.
- Resending the verification mail gets a per-address cooldown on top of the
  per-user limit.
- Issue API tokens with the narrower radio/ticket ability set instead of "*".

Authorization & input handling
- Mid-rank staff can no longer keep dynamically granted non-view admin.*
  permissions: existing grants are revoked by migration and the grant lookup
  is restricted to "%.view". Rank guards use the dynamic super-admin check.
- Alerting a user is permission-checked and audited like the other tools.
- Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user
  actions route, bulk user actions) are capped and rank-guarded, and bulk
  ids are bounded.
- updateRoom / updateRoomItem write through a field allowlist, and items
  may only be edited through their own room.
- Classnames reaching the filesystem are validated before use so a crafted
  value cannot escape the asset directories.
- The word filter now also covers offline mails, guild forum threads and
  replies, and user mottos.
- Media uploads are validated by magic bytes, /api/media requires the page
  edit permission, APP_URL must be configured once mail is enabled, and the
  diagnostics error route checks the fetch site header.

Admin tooling
- Secret settings render masked and cannot be overwritten with a blank or
  an arbitrary raw key; radio credentials are new password inputs.
- Commandocentrum balance changes are audited.
- Admin list pagination reads the caller's per-page instead of the max, and
  the log exporter caps offset and search length.

Performance
- Catalog translations are cached per module, with a cheap revision hash;
  the public online count uses a stale window instead of hammering the DB.
- The cache warmup now primes the payload the home route actually reads.
- TopHeader batches its queries into one round trip, and LCP avatars load
  eagerly.
- motion/react and sonner are no longer part of the root layout; the nav
  dropdown and mobile nav panels are lazy client chunks. Anonymous visitors
  again get the navigation chrome, and public pages get an edge cacheable
  response.

Accessibility
- Nested <main> elements in phase pages became <section>; the page entrance
  and route progress animations are pure CSS that respect reduced motion.
2026-10-09 16:19:48 +02:00

200 lines
6.0 KiB
TypeScript

// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
const state = vi.hoisted(() => ({
auth: vi.fn(),
authSession: undefined as { user: { id: string; name: string } } | null,
updateCall: undefined as unknown,
rconSetMotto: vi.fn(),
failDbUpdate: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
const databaseErrorClass = vi.hoisted(
() =>
class DatabaseError extends Error {
constructor(message: string, cause?: unknown) {
super(message);
this.name = "DatabaseError";
this.cause = cause;
}
},
);
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
vi.mock("@/lib/foundation/action", () => ({
authAction: (
opts: { schema?: z.ZodType },
handler: (ctx: any) => unknown,
) => {
return async (input: unknown) => {
const session = await state.auth();
if (!session?.user) return { ok: false, error: "Unauthorized" };
const ctx: any = {
session: {
user: { id: Number(session.user.id), name: session.user.name },
},
};
if (opts.schema) {
const parsed = opts.schema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: "Validation failed",
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}
ctx.data = parsed.data;
} else {
ctx.data = input;
}
return handler(ctx);
};
},
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/foundation/errors", () => ({
DatabaseError: databaseErrorClass,
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: { setMotto: state.rconSetMotto },
}));
vi.mock("@/lib/services/moderation", () => ({
isAllowed: state.isAllowed,
}));
const mockRevalidatePath = vi.hoisted(() => vi.fn());
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db");
const db = createFakeDb(() => []);
db.update = vi.fn((table) => ({
set: (values: unknown) => ({
where: () => {
state.updateCall = { table, values };
if (state.failDbUpdate)
return Promise.reject(new Error("connection lost"));
return Promise.resolve([{ affectedRows: 1 }]);
},
}),
}));
return { ...schema, db };
});
import { DatabaseError } from "@/lib/foundation/errors";
import { updateMotto, updateMottoAction } from "./user-settings";
const mockingForm = (motto: string): FormData =>
({
get: (key: string) => (key === "motto" ? motto : null),
}) as unknown as FormData;
beforeEach(() => {
vi.clearAllMocks();
state.auth.mockResolvedValue({ user: { id: "42", name: "player" } });
state.authSession = null;
state.updateCall = undefined;
state.rconSetMotto.mockResolvedValue(true);
state.failDbUpdate = false;
state.isAllowed.mockResolvedValue({ ok: true });
});
describe("updateMotto", () => {
it("persists the motto, syncs RCON and revalidates /settings", async () => {
await updateMotto(mockingForm(" hello world "));
expect(state.updateCall.values).toEqual({ motto: " hello world " });
expect(state.rconSetMotto).toHaveBeenCalledWith(42, " hello world ");
expect(mockRevalidatePath).toHaveBeenCalledWith("/settings");
});
it("normalises NFC and truncates the motto to 127 characters", async () => {
const long = "é".repeat(200);
await updateMotto(mockingForm(long));
expect(state.updateCall.values.motto).toHaveLength(127);
expect(state.rconSetMotto).toHaveBeenCalledWith(42, "é".repeat(127));
});
it("still succeeds when RCON fails (best-effort sync)", async () => {
state.rconSetMotto.mockRejectedValue(new Error("emulator down"));
await updateMotto(mockingForm("ok"));
expect(state.updateCall.values).toEqual({ motto: "ok" });
expect(mockRevalidatePath).toHaveBeenCalledWith("/settings");
});
it("throws a DatabaseError when the DB update fails", async () => {
state.failDbUpdate = true;
await expect(updateMotto(mockingForm("boom"))).rejects.toThrow(
databaseErrorClass,
);
await expect(updateMotto(mockingForm("boom"))).rejects.toThrow(
"Failed to update motto",
);
});
it("does not sync RCON when the DB update fails", async () => {
state.failDbUpdate = true;
await expect(updateMotto(mockingForm("boom"))).rejects.toThrow(
databaseErrorClass,
);
expect(state.rconSetMotto).not.toHaveBeenCalled();
});
});
describe("updateMotto word filter", () => {
it("rejects a motto blocked by the word filter before persisting", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
await expect(updateMotto(mockingForm("bad motto"))).rejects.toThrow(
databaseErrorClass,
);
expect(state.isAllowed).toHaveBeenCalledWith("bad motto");
expect(state.updateCall).toBeUndefined();
expect(state.rconSetMotto).not.toHaveBeenCalled();
});
});
describe("updateMottoAction", () => {
it("denies unauthenticated callers", async () => {
state.auth.mockResolvedValue(null);
expect(await updateMottoAction({ motto: "nope" })).toEqual({
ok: false,
error: "Unauthorized",
});
expect(state.updateCall).toBeUndefined();
});
it("rejects mottos longer than 127 characters", async () => {
const res = await updateMottoAction({ motto: "x".repeat(128) });
expect(res).toMatchObject({ ok: false, error: "Validation failed" });
expect(state.updateCall).toBeUndefined();
});
});
describe("schema boundary", () => {
it("accepts exactly 127 characters", async () => {
const res = await updateMottoAction({ motto: "x".repeat(127) });
expect(res).toEqual({ ok: true, data: {} });
expect(state.updateCall.values).toEqual({ motto: "x".repeat(127) });
});
it("rejects non-string mottos", async () => {
expect(
await updateMottoAction({ motto: 5 as unknown as string }),
).toMatchObject({ ok: false, error: "Validation failed" });
});
});
describe("DatabaseError propagation", () => {
it("is an instance of the exported error class", () => {
const err = new databaseErrorClass("db");
expect(err).toBeInstanceOf(DatabaseError);
expect(err.name).toBe("DatabaseError");
});
});