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
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.
200 lines
6.0 KiB
TypeScript
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");
|
|
});
|
|
});
|