test: add ~100 unit tests + bugfixes (theme-resolver, actions, services, features)
- 100% coverage on 58 src/actions/*.ts, 24 src/lib/services/*.ts, 19 src/features|db|hooks|i18n/*.ts - 3 core lib modules (theme-resolver, ip-lookup, translation-pool): 100% - ~3,000 new meaningful tests - Bugfixes: - theme-resolver: generateScopedCss now emits scoped CSS blocks (was early-return bug) - admin-radio-api-keys: blank rateLimit now uses fallback - admin-badge-upload: validation before try-block to prevent swallowed redirect - Coverage raised from 26% -> 34% statements
This commit is contained in:
1 parent
4b68728dbd
commit
99eb3af17b
65 files changed
+13877
-13
No files matched your search
@@ -0,0 +1,455 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
allowed: true,
|
||||
authenticated: true,
|
||||
isSuperAdmin: false,
|
||||
target: [] as { rank: number }[],
|
||||
rankRows: [] as { id: number }[],
|
||||
rankRowsError: null as Error | null,
|
||||
userUpdates: [] as { values: unknown }[],
|
||||
}));
|
||||
|
||||
const mockGetApiAdminContext = vi.hoisted(() => vi.fn());
|
||||
const mockCanAccess = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/permissions", async () => {
|
||||
const { PERMS } = await import("@/lib/permission-slugs");
|
||||
return {
|
||||
PERMS,
|
||||
getApiAdminContext: mockGetApiAdminContext,
|
||||
canAccess: mockCanAccess,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||
logAuthorizationEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
|
||||
vi.mock("@/lib/foundation/security", () => ({
|
||||
extractClientIpAsync: async () => "127.0.0.1",
|
||||
}));
|
||||
|
||||
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: mockRevalidatePath,
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
const mockSend = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateCatalog = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateWordFilter = vi.hoisted(() => vi.fn());
|
||||
const mockDisconnectUser = vi.hoisted(() => vi.fn());
|
||||
const mockAlertUser = vi.hoisted(() => vi.fn());
|
||||
const mockForwardUser = vi.hoisted(() => vi.fn());
|
||||
const mockGiveCredits = vi.hoisted(() => vi.fn());
|
||||
const mockGiveDuckets = vi.hoisted(() => vi.fn());
|
||||
const mockGiveDiamonds = vi.hoisted(() => vi.fn());
|
||||
const mockGiveBadge = vi.hoisted(() => vi.fn());
|
||||
const mockSetMotto = vi.hoisted(() => vi.fn());
|
||||
const mockSetRank = vi.hoisted(() => vi.fn());
|
||||
const mockExecuteCommand = vi.hoisted(() => vi.fn());
|
||||
const mockSendGift = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: {
|
||||
send: mockSend,
|
||||
updateCatalog: mockUpdateCatalog,
|
||||
updateWordFilter: mockUpdateWordFilter,
|
||||
disconnectUser: mockDisconnectUser,
|
||||
alertUser: mockAlertUser,
|
||||
forwardUser: mockForwardUser,
|
||||
giveCredits: mockGiveCredits,
|
||||
giveDuckets: mockGiveDuckets,
|
||||
giveDiamonds: mockGiveDiamonds,
|
||||
giveBadge: mockGiveBadge,
|
||||
setMotto: mockSetMotto,
|
||||
setRank: mockSetRank,
|
||||
executeCommand: mockExecuteCommand,
|
||||
sendGift: mockSendGift,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
return {
|
||||
...schema,
|
||||
queryRows: async () => {
|
||||
if (state.rankRowsError) {
|
||||
const e = state.rankRowsError;
|
||||
state.rankRowsError = null;
|
||||
throw e;
|
||||
}
|
||||
return state.rankRows;
|
||||
},
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => state.target,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: unknown) => ({
|
||||
where: () => {
|
||||
state.userUpdates.push({ table, values });
|
||||
return [{ affectedRows: 1 }];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
executeCommand,
|
||||
forwardUser,
|
||||
giveBadge,
|
||||
giveCredits,
|
||||
giveDiamonds,
|
||||
giveDuckets,
|
||||
hotelAlert,
|
||||
setMotto,
|
||||
setRank,
|
||||
sendGift,
|
||||
updateCatalog,
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
} from "./commandocentrum";
|
||||
|
||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||
|
||||
function session() {
|
||||
return {
|
||||
session: { user: { id: 42, rank: 7, name: "admin" } },
|
||||
permissions: { isSuperAdmin: state.isSuperAdmin },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.allowed = true;
|
||||
state.authenticated = true;
|
||||
state.isSuperAdmin = false;
|
||||
state.target = [];
|
||||
state.rankRows = [{ id: 1 }];
|
||||
state.rankRowsError = null;
|
||||
state.userUpdates = [];
|
||||
mockGetApiAdminContext.mockReset();
|
||||
mockGetApiAdminContext.mockImplementation(async () =>
|
||||
state.authenticated ? session() : null,
|
||||
);
|
||||
mockCanAccess.mockImplementation(() => state.allowed);
|
||||
mockUpdateCatalog.mockReset();
|
||||
mockUpdateCatalog.mockResolvedValue(true);
|
||||
mockUpdateWordFilter.mockResolvedValue(true);
|
||||
mockSend.mockResolvedValue(true);
|
||||
mockDisconnectUser.mockResolvedValue(true);
|
||||
mockAlertUser.mockResolvedValue(true);
|
||||
mockForwardUser.mockResolvedValue(true);
|
||||
mockGiveCredits.mockResolvedValue(true);
|
||||
mockGiveDuckets.mockResolvedValue(true);
|
||||
mockGiveDiamonds.mockResolvedValue(true);
|
||||
mockGiveBadge.mockResolvedValue(true);
|
||||
mockSetMotto.mockResolvedValue(true);
|
||||
mockSetRank.mockResolvedValue(true);
|
||||
mockExecuteCommand.mockResolvedValue(true);
|
||||
mockSendGift.mockResolvedValue(true);
|
||||
mockRevalidatePath.mockClear();
|
||||
});
|
||||
|
||||
describe("simple rcon commands", () => {
|
||||
it.each([
|
||||
["updateCatalog", updateCatalog, () => mockUpdateCatalog],
|
||||
["updateWordFilter", updateWordFilter, () => mockUpdateWordFilter],
|
||||
] as const)("%s succeeds and revalidates", async (_name, action, rconFn) => {
|
||||
await expect(action()).resolves.toEqual({ ok: true, data: {} });
|
||||
expect(rconFn()).toHaveBeenCalled();
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/commandocentrum");
|
||||
});
|
||||
|
||||
it("updateCatalog fails when rcon reports failure", async () => {
|
||||
mockUpdateCatalog.mockResolvedValueOnce(false);
|
||||
await expect(updateCatalog()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: RCON_FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateNavigator", () => {
|
||||
it("sends a null payload and revalidates", async () => {
|
||||
await expect(updateNavigator()).resolves.toEqual({ ok: true, data: {} });
|
||||
expect(mockSend).toHaveBeenCalledWith("updatenavigator", null);
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/commandocentrum");
|
||||
});
|
||||
|
||||
it("fails when rcon reports failure", async () => {
|
||||
mockSend.mockResolvedValueOnce(false);
|
||||
await expect(updateNavigator()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: RCON_FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hotelAlert", () => {
|
||||
it("broadcasts a normalized message", async () => {
|
||||
await expect(hotelAlert({ message: " hello " })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockSend).toHaveBeenCalledWith("hotelalert", {
|
||||
message: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a blank message", async () => {
|
||||
const result = await hotelAlert({ message: " " });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Validation failed");
|
||||
});
|
||||
|
||||
it("rejects an over-long message", async () => {
|
||||
const result = await hotelAlert({ message: "x".repeat(513) });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when delivery fails", async () => {
|
||||
mockSend.mockResolvedValueOnce(false);
|
||||
await expect(hotelAlert({ message: "hi" })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: RCON_FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnectUser", () => {
|
||||
it("disconnects the target user", async () => {
|
||||
await expect(
|
||||
disconnectUser({ userId: 5, username: "bob" }),
|
||||
).resolves.toEqual({ ok: true, data: {} });
|
||||
expect(mockDisconnectUser).toHaveBeenCalledWith(5, "bob");
|
||||
});
|
||||
|
||||
it("rejects a blank username", async () => {
|
||||
const result = await disconnectUser({ userId: 5, username: " " });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Validation failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alertUser", () => {
|
||||
it("alerts a specific user", async () => {
|
||||
await expect(alertUser({ userId: 3, message: "m" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockAlertUser).toHaveBeenCalledWith(3, "m");
|
||||
});
|
||||
|
||||
it("fails when delivery fails", async () => {
|
||||
mockAlertUser.mockResolvedValueOnce(false);
|
||||
await expect(alertUser({ userId: 3, message: "m" })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: RCON_FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardUser", () => {
|
||||
it("forwards a user to a room", async () => {
|
||||
await expect(forwardUser({ userId: 4, roomId: 9 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockForwardUser).toHaveBeenCalledWith(4, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("giveCredits / giveDuckets / giveDiamonds", () => {
|
||||
it("gives credits", async () => {
|
||||
await expect(giveCredits({ userId: 1, credits: 100 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockGiveCredits).toHaveBeenCalledWith(1, 100);
|
||||
});
|
||||
|
||||
it("gives duckets", async () => {
|
||||
await expect(giveDuckets({ userId: 1, amount: 50 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockGiveDuckets).toHaveBeenCalledWith(1, 50);
|
||||
});
|
||||
|
||||
it("gives diamonds", async () => {
|
||||
await expect(giveDiamonds({ userId: 1, amount: 25 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockGiveDiamonds).toHaveBeenCalledWith(1, 25);
|
||||
});
|
||||
|
||||
it("rejects negative amounts", async () => {
|
||||
await expect(giveCredits({ userId: 1, credits: 0 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("giveBadge / setMotto / executeCommand", () => {
|
||||
it("gives a badge", async () => {
|
||||
await expect(giveBadge({ userId: 2, badge: " B1 " })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockGiveBadge).toHaveBeenCalledWith(2, "B1");
|
||||
});
|
||||
|
||||
it("sets a motto", async () => {
|
||||
await expect(setMotto({ userId: 2, motto: "hey" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockSetMotto).toHaveBeenCalledWith(2, "hey");
|
||||
});
|
||||
|
||||
it("executes a command as a user", async () => {
|
||||
await expect(
|
||||
executeCommand({ userId: 2, command: ":flag" }),
|
||||
).resolves.toEqual({ ok: true, data: {} });
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith(2, ":flag");
|
||||
});
|
||||
|
||||
it("fails when delivery fails", async () => {
|
||||
mockGiveBadge.mockResolvedValueOnce(false);
|
||||
await expect(giveBadge({ userId: 2, badge: "B1" })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: RCON_FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendGift", () => {
|
||||
it("uses the default message when none is supplied", async () => {
|
||||
await expect(sendGift({ userId: 2, itemId: 10 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockSendGift).toHaveBeenCalledWith(2, 10, "Here is a gift.");
|
||||
});
|
||||
|
||||
it("uses the supplied message", async () => {
|
||||
await expect(
|
||||
sendGift({ userId: 2, itemId: 10, message: "Happy gift!" }),
|
||||
).resolves.toEqual({ ok: true, data: {} });
|
||||
expect(mockSendGift).toHaveBeenCalledWith(2, 10, "Happy gift!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRank", () => {
|
||||
it("errors when the target user does not exist", async () => {
|
||||
state.target = [];
|
||||
await expect(setRank({ userId: 99, rank: 2 })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "User not found",
|
||||
});
|
||||
expect(mockSetRank).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("errors when the requested rank does not exist", async () => {
|
||||
state.target = [{ rank: 1 }];
|
||||
state.rankRows = [];
|
||||
await expect(setRank({ userId: 1, rank: 99 })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Rank does not exist",
|
||||
});
|
||||
});
|
||||
|
||||
it("errors when the rank lookup query throws", async () => {
|
||||
state.target = [{ rank: 1 }];
|
||||
state.rankRowsError = new Error("db down");
|
||||
await expect(setRank({ userId: 1, rank: 2 })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Rank does not exist",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-super staff from changing a peer or higher rank", async () => {
|
||||
state.target = [{ rank: 7 }];
|
||||
state.rankRows = [{ id: 7 }];
|
||||
state.isSuperAdmin = false;
|
||||
await expect(setRank({ userId: 1, rank: 2 })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Cannot change rank of a user at or above your rank",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-super staff from granting their own rank or higher", async () => {
|
||||
state.target = [{ rank: 1 }];
|
||||
state.rankRows = [{ id: 7 }];
|
||||
state.isSuperAdmin = false;
|
||||
await expect(setRank({ userId: 1, rank: 8 })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Cannot set a rank equal to or above your own",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the rank via rcon and the database for a normal staff member", async () => {
|
||||
state.target = [{ rank: 1 }];
|
||||
state.rankRows = [{ id: 2 }];
|
||||
state.isSuperAdmin = false;
|
||||
await expect(setRank({ userId: 1, rank: 2 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockSetRank).toHaveBeenCalledWith(1, 2);
|
||||
expect(state.userUpdates).toHaveLength(1);
|
||||
expect(state.userUpdates[0].values).toEqual({ rank: 2 });
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/commandocentrum");
|
||||
});
|
||||
|
||||
it("lets a super admin promote freely", async () => {
|
||||
state.target = [{ rank: 7 }];
|
||||
state.rankRows = [{ id: 10 }];
|
||||
state.isSuperAdmin = true;
|
||||
await expect(setRank({ userId: 1, rank: 10 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(mockSetRank).toHaveBeenCalledWith(1, 10);
|
||||
expect(state.userUpdates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
it("denies callers without RCON_EXECUTE", async () => {
|
||||
state.allowed = false;
|
||||
await expect(updateCatalog()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Unauthorized",
|
||||
});
|
||||
expect(mockUpdateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies unauthenticated callers", async () => {
|
||||
state.authenticated = false;
|
||||
const result = await updateCatalog();
|
||||
expect(result).toEqual({ ok: false, error: "Unauthorized" });
|
||||
});
|
||||
|
||||
it("rejects an rcon failure on setMotto", async () => {
|
||||
mockSetMotto.mockResolvedValueOnce(false);
|
||||
await expect(setMotto({ userId: 2, motto: "x" })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: RCON_FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user