Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
- Remove .prettierrc (dead config, Biome replaces Prettier) - Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat - Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit - Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts - Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars - Replace barrel export src/types/index.ts with direct @/types/common imports - Make trustHost conditional (development only) in auth.ts - Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations - Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
This commit is contained in:
1 parent
b922f6d49f
commit
17847545dd
292 files changed
+69195
-67915
No files matched your search
@@ -6,20 +6,20 @@ vi.mock("@/lib/logger", () => ({
|
||||
logger: { error: loggerError },
|
||||
}));
|
||||
|
||||
import { wrapAction, actionError } from "./action-helper";
|
||||
import { actionError, wrapAction } from "./action-helper";
|
||||
|
||||
describe("wrapAction", () => {
|
||||
it("returns success result on happy path", async () => {
|
||||
const fn = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const result = await wrapAction(fn, "test_action");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it("returns error result on thrown exception", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("DB error"));
|
||||
const result = await wrapAction(fn, "failing_action");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("DB error");
|
||||
expect(loggerError).toHaveBeenCalled();
|
||||
});
|
||||
@@ -27,20 +27,20 @@ describe("wrapAction", () => {
|
||||
it("catches non-Error values gracefully", async () => {
|
||||
const fn = vi.fn().mockRejectedValue("string error");
|
||||
const result = await wrapAction(fn, "bad_action");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns data returned by fn", async () => {
|
||||
const fn = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await wrapAction(fn, "void_action");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actionError", () => {
|
||||
it("returns failure result with message", () => {
|
||||
const result = actionError("some_action", "something went wrong");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("something went wrong");
|
||||
});
|
||||
|
||||
@@ -48,7 +48,10 @@ describe("actionError", () => {
|
||||
actionError("audit_action", "audit failed");
|
||||
expect(loggerError).toHaveBeenCalledWith(
|
||||
"Action failed: audit_action",
|
||||
expect.objectContaining({ action: "audit_action", error: "audit failed" }),
|
||||
expect.objectContaining({
|
||||
action: "audit_action",
|
||||
error: "audit failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-19
@@ -1,29 +1,35 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export interface ActionResult<T = void> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function wrapAction<T>(
|
||||
fn: () => Promise<T>,
|
||||
actionName: string,
|
||||
fn: () => Promise<T>,
|
||||
actionName: string,
|
||||
): Promise<ActionResult<T>> {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
logger.error(`Action failed: ${actionName}`, {
|
||||
action: actionName,
|
||||
error: message,
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
try {
|
||||
const data = await fn();
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
logger.error(`Action failed: ${actionName}`, {
|
||||
action: actionName,
|
||||
error: message,
|
||||
});
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export function actionError(actionName: string, message: string): ActionResult<never> {
|
||||
logger.error(`Action failed: ${actionName}`, { action: actionName, error: message });
|
||||
return { success: false, error: message };
|
||||
export function actionError(
|
||||
actionName: string,
|
||||
message: string,
|
||||
): ActionResult<never> {
|
||||
logger.error(`Action failed: ${actionName}`, {
|
||||
action: actionName,
|
||||
error: message,
|
||||
});
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("admin CSRF wiring", () => {
|
||||
it("defaults CSRF on for mutating withAdmin handlers", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ListParams } from "@/types";
|
||||
import type { ListParams } from "@/types/common";
|
||||
|
||||
export const PER_PAGE_OPTIONS = [10, 20, 50] as const;
|
||||
const DEFAULT_PER_PAGE = 20;
|
||||
@@ -31,4 +31,3 @@ export function calcPagination(total: number, page: number, perPage: number) {
|
||||
offset: (Math.min(page, lastPage) - 1) * perPage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
||||
+6
-10
@@ -18,16 +18,12 @@ describe("positiveBigInt", () => {
|
||||
expect(positiveBigInt(raw)).toBe(BigInt(raw));
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1.5",
|
||||
"abc",
|
||||
])("rejects invalid id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBeNull();
|
||||
});
|
||||
it.each([null, "", "0", "-1", "1.5", "abc"])(
|
||||
"rejects invalid id %s",
|
||||
(raw) => {
|
||||
expect(positiveBigInt(raw)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
|
||||
+13
-12
@@ -1,10 +1,11 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { env } from "@/env";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -23,7 +24,9 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
||||
if (verifyTotp(code, secret)) return true;
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
logger.warn(
|
||||
"2FA TOTP verification failed, falling through to recovery codes",
|
||||
);
|
||||
}
|
||||
|
||||
// Try recovery codes
|
||||
@@ -32,6 +35,7 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
logger.warn("Failed to parse 2FA recovery codes JSON");
|
||||
return false;
|
||||
}
|
||||
const idx = codes.indexOf(code);
|
||||
@@ -50,7 +54,7 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export const { handlers, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
trustHost: env.NODE_ENV !== "production",
|
||||
secret: env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
||||
pages: { signIn: "/login" },
|
||||
@@ -131,7 +135,9 @@ export const { handlers, signOut, auth } = NextAuth({
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
logger.warn("Failed to record login log for user", {
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -147,9 +153,7 @@ export const { handlers, signOut, auth } = NextAuth({
|
||||
async jwt({ token, user, account }) {
|
||||
if (user) {
|
||||
token.jwtVersion =
|
||||
(user as { jwtVersion?: number }).jwtVersion ??
|
||||
token.jwtVersion ??
|
||||
0;
|
||||
(user as { jwtVersion?: number }).jwtVersion ?? token.jwtVersion ?? 0;
|
||||
token.jwtCheckedAt = Date.now();
|
||||
}
|
||||
|
||||
@@ -166,17 +170,14 @@ export const { handlers, signOut, auth } = NextAuth({
|
||||
if (Date.now() - lastCheck >= 60_000) {
|
||||
try {
|
||||
const version = await getCachedJwtVersion(Number(token.sub));
|
||||
if (
|
||||
version === null ||
|
||||
(token.jwtVersion ?? 0) !== version
|
||||
) {
|
||||
if (version === null || (token.jwtVersion ?? 0) !== version) {
|
||||
token.invalid = true;
|
||||
delete token.sub;
|
||||
return token;
|
||||
}
|
||||
token.jwtCheckedAt = Date.now();
|
||||
} catch {
|
||||
/* keep session on transient DB/cache errors */
|
||||
logger.warn("JWT version check failed, keeping session");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,9 @@ describe("checkLogin", () => {
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
|
||||
@@ -25,11 +25,10 @@ describe("databaseUserId", () => {
|
||||
expect(databaseUserId(42n)).toBe(42);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0n,
|
||||
-1n,
|
||||
BigInt(Number.MAX_SAFE_INTEGER) + 1n,
|
||||
])("rejects unsafe database id %s", (value) => {
|
||||
expect(databaseUserId(value)).toBeNull();
|
||||
});
|
||||
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])(
|
||||
"rejects unsafe database id %s",
|
||||
(value) => {
|
||||
expect(databaseUserId(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -22,10 +22,10 @@ describe("production deploy workflow", () => {
|
||||
|
||||
it("reclaims ownership before git operations so www-data files can be overwritten", () => {
|
||||
expect(workflow).toContain(
|
||||
'sudo chown -R "$' + "{DEPLOY_USER}:" + '$' + '{DEPLOY_GROUP}"',
|
||||
'sudo chown -R "$' + "{DEPLOY_USER}:" + "$" + '{DEPLOY_GROUP}"',
|
||||
);
|
||||
const reclaimAt = deployJob.indexOf(
|
||||
'sudo chown -R "$' + "{DEPLOY_USER}:" + '$' + '{DEPLOY_GROUP}"',
|
||||
'sudo chown -R "$' + "{DEPLOY_USER}:" + "$" + '{DEPLOY_GROUP}"',
|
||||
);
|
||||
const fetchAt = deployJob.indexOf('git -C "${LIVE}" fetch origin --prune');
|
||||
expect(reclaimAt).toBeGreaterThan(-1);
|
||||
@@ -79,7 +79,7 @@ describe("production deploy workflow", () => {
|
||||
'export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"',
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
'export NEXT_PUBLIC_APP_VERSION="$' + "{APP_VERSION}\"",
|
||||
'export NEXT_PUBLIC_APP_VERSION="$' + '{APP_VERSION}"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,16 +9,13 @@ describe("formPositiveBigInt", () => {
|
||||
expect(formPositiveBigInt(formData, "id")).toBe(42n);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1.5",
|
||||
"invalid",
|
||||
])("rejects invalid identifier %s", (value) => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", value);
|
||||
it.each(["", "0", "-1", "1.5", "invalid"])(
|
||||
"rejects invalid identifier %s",
|
||||
(value) => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", value);
|
||||
|
||||
expect(formPositiveBigInt(formData, "id")).toBeNull();
|
||||
});
|
||||
expect(formPositiveBigInt(formData, "id")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
+6
-1
@@ -4,7 +4,12 @@ import { resolveImagerBase } from "@/lib/imager";
|
||||
export function avatarImageUrl(
|
||||
base: string,
|
||||
look: string,
|
||||
opts: { size?: "s" | "m" | "l"; headOnly?: boolean; direction?: number; headDirection?: number } = {},
|
||||
opts: {
|
||||
size?: "s" | "m" | "l";
|
||||
headOnly?: boolean;
|
||||
direction?: number;
|
||||
headDirection?: number;
|
||||
} = {},
|
||||
): string {
|
||||
const resolved = resolveImagerBase(base);
|
||||
const params = new URLSearchParams({ figure: look, img_format: "png" });
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { IpAddress } from "./types";
|
||||
|
||||
const CSRF_BYTES = 32;
|
||||
@@ -23,12 +24,14 @@ async function isRequestSecure(): Promise<boolean> {
|
||||
if (proto === "https") return true;
|
||||
if (proto === "http") return false;
|
||||
} catch {
|
||||
// headers unavailable during static analysis
|
||||
logger.warn(
|
||||
"Headers unavailable during static analysis in isRequestSecure",
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (env.APP_URL) return new URL(env.APP_URL).protocol === "https:";
|
||||
} catch {
|
||||
// ignore malformed APP_URL
|
||||
logger.warn("Malformed APP_URL in isRequestSecure");
|
||||
}
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
@@ -86,6 +89,7 @@ export function safeRedirect(
|
||||
if (url.host === "localhost" || url.host === "127.0.0.1")
|
||||
return destination;
|
||||
} catch {
|
||||
logger.warn("Failed to parse redirect URL", { destination });
|
||||
if (isSafePath(destination)) return destination;
|
||||
}
|
||||
return fallback;
|
||||
@@ -130,6 +134,7 @@ function trySetCsrfCookie(
|
||||
c.set(opts.name, opts.value, opts);
|
||||
return true;
|
||||
} catch {
|
||||
logger.warn("Failed to set CSRF cookie", { name: opts.name });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -155,6 +160,7 @@ export async function setCsrfCookie(): Promise<string> {
|
||||
|
||||
return "";
|
||||
} catch {
|
||||
logger.warn("Failed to set CSRF cookie (outer)");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -174,6 +180,7 @@ export async function validateCsrfToken(token: string): Promise<boolean> {
|
||||
if (!stored) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||
} catch {
|
||||
logger.warn("CSRF token validation failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -244,6 +251,7 @@ export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
logger.warn("Failed to get client IP from headers");
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,3 @@ export async function resolveHotelName(): Promise<string> {
|
||||
if (fromEnv) return fromEnv;
|
||||
return FALLBACK_HOTEL_NAME;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAvatarUrl,
|
||||
resolveImagerBase,
|
||||
resolveUpstreamBase,
|
||||
} from "./imager";
|
||||
import { getAvatarUrl, resolveImagerBase, resolveUpstreamBase } from "./imager";
|
||||
|
||||
describe("resolveImagerBase", () => {
|
||||
it("defaults to /imaging when no public URL env is set", () => {
|
||||
|
||||
+3
-2
@@ -8,13 +8,14 @@
|
||||
|
||||
export type { AvatarOptions } from "@/types/admin";
|
||||
|
||||
import type { AvatarOptions } from "@/types/admin";
|
||||
import { env } from "@/env";
|
||||
import type { AvatarOptions } from "@/types/admin";
|
||||
|
||||
/** Build an absolute default imager URL from public env vars. */
|
||||
function defaultImagerUrl(): string {
|
||||
const pub = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||
if (pub && !pub.includes("/api/imaging/avatar")) return pub.replace(/\/+$/, "");
|
||||
if (pub && !pub.includes("/api/imaging/avatar"))
|
||||
return pub.replace(/\/+$/, "");
|
||||
const app = env.NEXT_PUBLIC_APP_URL?.trim();
|
||||
if (app) return `${app.replace(/\/+$/, "")}/imaging`;
|
||||
return "/imaging";
|
||||
|
||||
+1
-2
@@ -8,8 +8,7 @@ const level: LogLevel =
|
||||
(env.NODE_ENV === "production" ? "info" : "debug");
|
||||
|
||||
const isProd = env.NODE_ENV === "production";
|
||||
const isTest =
|
||||
env.NODE_ENV === "test" || process.env.VITEST === "true";
|
||||
const isTest = env.NODE_ENV === "test" || process.env.VITEST === "true";
|
||||
|
||||
const pinoLogger = pino({
|
||||
level,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const CACHE_TTL: Record<string, number> = {
|
||||
@@ -26,7 +27,9 @@ function shouldCache(model: string, action: string): boolean {
|
||||
}
|
||||
|
||||
function queryKey(model: string, action: string, args: unknown): string {
|
||||
const hash = JSON.stringify(args).replace(/["{}[\],]/g, "").slice(0, 120);
|
||||
const hash = JSON.stringify(args)
|
||||
.replace(/["{}[\],]/g, "")
|
||||
.slice(0, 120);
|
||||
return `pq:${model}:${action}:${hash}`;
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ async function _cachedQuery<T>(
|
||||
return JSON.parse(cached) as T;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
logger.warn("Cache read failed", { key });
|
||||
}
|
||||
|
||||
const result = await fn();
|
||||
@@ -65,7 +68,7 @@ async function _cachedQuery<T>(
|
||||
try {
|
||||
await redis.setex(key, ttl, JSON.stringify(result));
|
||||
} catch {
|
||||
// ignore
|
||||
logger.warn("Cache write failed", { key });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { resolvePrismaConnectionLimit } from "./prisma-pool";
|
||||
describe("resolvePrismaConnectionLimit", () => {
|
||||
it("caps the pool during production build phases", () => {
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5);
|
||||
expect(
|
||||
resolvePrismaConnectionLimit(40, "phase-production-compile"),
|
||||
).toBe(5);
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-production-compile")).toBe(
|
||||
5,
|
||||
);
|
||||
expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import { headers } from "next/headers";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
|
||||
+8
-6
@@ -12,10 +12,7 @@ const globalForRedis = globalThis as unknown as {
|
||||
function createRedis(): Redis | null {
|
||||
const url = env.REDIS_URL;
|
||||
if (!url) {
|
||||
if (
|
||||
env.NODE_ENV === "production" &&
|
||||
!globalForRedis.redisMissingWarned
|
||||
) {
|
||||
if (env.NODE_ENV === "production" && !globalForRedis.redisMissingWarned) {
|
||||
globalForRedis.redisMissingWarned = true;
|
||||
logger.error(
|
||||
"[redis] REDIS_URL is unset in production. Rate limits, site-settings cache, and JWT session invalidation fall back to in-process memory and will not work correctly across multiple instances or restarts. Set REDIS_URL in .env.",
|
||||
@@ -32,9 +29,14 @@ function createRedis(): Redis | null {
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
client.on("error", () => {});
|
||||
client.on("error", (err) => {
|
||||
logger.warn("[redis] Connection error", { error: String(err) });
|
||||
});
|
||||
return client;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logger.warn("[redis] Failed to create Redis client", {
|
||||
error: String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ export function handleActionError(error: unknown): ActionResult<never> {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
fieldErrors: z.flattenError(error).fieldErrors as Record<string, string[]>,
|
||||
fieldErrors: z.flattenError(error).fieldErrors as Record<
|
||||
string,
|
||||
string[]
|
||||
>,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error && error.name === "ActionError") {
|
||||
|
||||
@@ -43,7 +43,8 @@ export function redactSentryEvent(
|
||||
) as typeof event.request.data;
|
||||
}
|
||||
}
|
||||
if (event.extra) event.extra = redactObject(event.extra) as typeof event.extra;
|
||||
if (event.extra)
|
||||
event.extra = redactObject(event.extra) as typeof event.extra;
|
||||
if (event.contexts) {
|
||||
event.contexts = redactObject(event.contexts) as typeof event.contexts;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
const create = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createFn = vi.hoisted(() => vi.fn());
|
||||
const sendMailFn = vi.hoisted(() => vi.fn().mockResolvedValue(true));
|
||||
|
||||
@@ -16,7 +16,6 @@ import { sendMail } from "@/lib/services/email";
|
||||
// fetch (Node 18+/Next 16) — no extra packages.
|
||||
//
|
||||
|
||||
|
||||
export type AlertSeverity =
|
||||
| "info"
|
||||
| "notice"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const create = vi.hoisted(() => vi.fn());
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/lib/prisma", () => ({
|
||||
|
||||
vi.mock("@/env", () => ({ env: {} }));
|
||||
|
||||
import { logAudit, getAuditLogs } from "./audit";
|
||||
import { getAuditLogs, logAudit } from "./audit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -87,8 +87,28 @@ describe("logAudit", () => {
|
||||
describe("getAuditLogs", () => {
|
||||
it("returns paginated logs with usernames", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ id: 1, userId: 1, action: "test", target: "user", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-01" },
|
||||
{ id: 2, userId: 2, action: "test2", target: "room", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-02" },
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
action: "test",
|
||||
target: "user",
|
||||
targetId: null,
|
||||
before: null,
|
||||
after: null,
|
||||
diff: null,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 2,
|
||||
action: "test2",
|
||||
target: "room",
|
||||
targetId: null,
|
||||
before: null,
|
||||
after: null,
|
||||
diff: null,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
]);
|
||||
count.mockResolvedValue(2);
|
||||
userFindMany.mockResolvedValue([
|
||||
@@ -110,14 +130,29 @@ describe("getAuditLogs", () => {
|
||||
await getAuditLogs({ search: "test" });
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { OR: [{ action: { contains: "test" } }, { target: { contains: "test" } }] },
|
||||
where: {
|
||||
OR: [
|
||||
{ action: { contains: "test" } },
|
||||
{ target: { contains: "test" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to User #id for unknown users", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ id: 1, userId: 99, action: "x", target: "y", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-01" },
|
||||
{
|
||||
id: 1,
|
||||
userId: 99,
|
||||
action: "x",
|
||||
target: "y",
|
||||
targetId: null,
|
||||
before: null,
|
||||
after: null,
|
||||
diff: null,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
]);
|
||||
count.mockResolvedValue(1);
|
||||
userFindMany.mockResolvedValue([]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.hoisted(() => vi.fn());
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
@@ -11,7 +11,14 @@ const catalogUpdateMany = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
catalogPages: { findUnique, findMany, update, create, delete: catalogDelete, updateMany: catalogUpdateMany },
|
||||
catalogPages: {
|
||||
findUnique,
|
||||
findMany,
|
||||
update,
|
||||
create,
|
||||
delete: catalogDelete,
|
||||
updateMany: catalogUpdateMany,
|
||||
},
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: executeRaw,
|
||||
},
|
||||
@@ -19,8 +26,15 @@ vi.mock("@/lib/prisma", () => ({
|
||||
|
||||
vi.mock("@/env", () => ({ env: {} }));
|
||||
|
||||
import { buildNestedTree, toInt, movePage, deletePage, getAncestors, createPage } from "./catalog-tree";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
import {
|
||||
buildNestedTree,
|
||||
createPage,
|
||||
deletePage,
|
||||
getAncestors,
|
||||
movePage,
|
||||
toInt,
|
||||
} from "./catalog-tree";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -44,9 +58,48 @@ describe("toInt", () => {
|
||||
|
||||
describe("buildNestedTree", () => {
|
||||
const flat: TreeNode[] = [
|
||||
{ id: 1, caption: "Root", parentId: -1, depth: 0, orderNum: 1, enabled: "1", visible: "1", iconImage: 0, iconColor: 0, pageLayout: "default_3x3", childCount: 2, itemCount: 0 },
|
||||
{ id: 2, caption: "Child", parentId: 1, depth: 1, orderNum: 1, enabled: "1", visible: "1", iconImage: 0, iconColor: 0, pageLayout: "default_3x3", childCount: 1, itemCount: 5 },
|
||||
{ id: 3, caption: "Grandchild", parentId: 2, depth: 2, orderNum: 1, enabled: "1", visible: "1", iconImage: 0, iconColor: 0, pageLayout: "default_3x3", childCount: 0, itemCount: 0 },
|
||||
{
|
||||
id: 1,
|
||||
caption: "Root",
|
||||
parentId: -1,
|
||||
depth: 0,
|
||||
orderNum: 1,
|
||||
enabled: "1",
|
||||
visible: "1",
|
||||
iconImage: 0,
|
||||
iconColor: 0,
|
||||
pageLayout: "default_3x3",
|
||||
childCount: 2,
|
||||
itemCount: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
caption: "Child",
|
||||
parentId: 1,
|
||||
depth: 1,
|
||||
orderNum: 1,
|
||||
enabled: "1",
|
||||
visible: "1",
|
||||
iconImage: 0,
|
||||
iconColor: 0,
|
||||
pageLayout: "default_3x3",
|
||||
childCount: 1,
|
||||
itemCount: 5,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
caption: "Grandchild",
|
||||
parentId: 2,
|
||||
depth: 2,
|
||||
orderNum: 1,
|
||||
enabled: "1",
|
||||
visible: "1",
|
||||
iconImage: 0,
|
||||
iconColor: 0,
|
||||
pageLayout: "default_3x3",
|
||||
childCount: 0,
|
||||
itemCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
it("builds nested tree from flat nodes", () => {
|
||||
@@ -89,7 +142,12 @@ describe("createPage", () => {
|
||||
|
||||
it("converts boolean visible/enabled to string", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
await createPage({ parentId: 0, caption: "x", visible: false, enabled: false });
|
||||
await createPage({
|
||||
parentId: 0,
|
||||
caption: "x",
|
||||
visible: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(create.mock.calls[0][0].data.visible).toBe("0");
|
||||
expect(create.mock.calls[0][0].data.enabled).toBe("0");
|
||||
});
|
||||
|
||||
@@ -234,10 +234,10 @@ export async function cloneSingleFurni(params: {
|
||||
// Rollback: remove both downloaded files so we don't leave orphaned assets.
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {});
|
||||
logger.warn(
|
||||
"[clone-import] items_base insert failed for",
|
||||
{ classname, error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[clone-import] items_base insert failed for", {
|
||||
classname,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
@@ -254,10 +254,10 @@ export async function cloneSingleFurni(params: {
|
||||
itemType,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[clone-import] FurnitureData append failed for",
|
||||
{ classname, error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[clone-import] FurnitureData append failed for", {
|
||||
classname,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -272,10 +272,10 @@ export async function cloneSingleFurni(params: {
|
||||
return nextCatalogId;
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[clone-import] catalog entry failed for",
|
||||
{ classname, error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[clone-import] catalog entry failed for", {
|
||||
classname,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
warnings.push(`catalog entry failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -436,7 +436,6 @@ export async function importSingleFurni(params: {
|
||||
const starIdx = classname.indexOf("*");
|
||||
const hasColor = starIdx !== -1;
|
||||
const baseClassname = hasColor ? classname.substring(0, starIdx) : classname;
|
||||
const _colorNum = hasColor ? classname.substring(starIdx + 1) : "2";
|
||||
const iconFileName = classname.replace(/\*/g, "_");
|
||||
const safeSwfName = baseClassname;
|
||||
const safeNitroName = baseClassname;
|
||||
@@ -536,10 +535,9 @@ export async function importSingleFurni(params: {
|
||||
warnings.push("SWF not available for Nitro conversion");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[import-furni] SWF to Nitro conversion failed",
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[import-furni] SWF to Nitro conversion failed", {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
warnings.push(
|
||||
`SWF to Nitro conversion failed: ${(err as Error).message}`,
|
||||
);
|
||||
@@ -665,10 +663,9 @@ export async function importSingleFurni(params: {
|
||||
try {
|
||||
await appendFurniEntry(furniEntry, itemType);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[import-furni] Failed to update FurnitureData.json",
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[import-furni] Failed to update FurnitureData.json", {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
warnings.push("FurnitureData.json update failed");
|
||||
}
|
||||
}
|
||||
@@ -707,10 +704,9 @@ export async function importSingleFurni(params: {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[import-furni] Failed to create catalog entry",
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[import-furni] Failed to create catalog entry", {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
warnings.push("Catalog entry creation failed");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
export type { HabboItFurniEntry } from "@/types/furni";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { HabboItFurniEntry } from "@/types/furni";
|
||||
import {
|
||||
habboFurnidataUrl,
|
||||
type HabboGamedataHotel,
|
||||
habboFurnidataUrl,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
import type { HabboItFurniEntry } from "@/types/furni";
|
||||
|
||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
@@ -30,11 +30,7 @@ export async function getHabboItFurnidata(): Promise<
|
||||
const hotel = await getHabboGamedataHotel();
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
cache &&
|
||||
cacheHotel === hotel &&
|
||||
now - cacheTimestamp < CACHE_TTL
|
||||
) {
|
||||
if (cache && cacheHotel === hotel && now - cacheTimestamp < CACHE_TTL) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
@@ -125,10 +121,9 @@ export async function getHabboItFurnidata(): Promise<
|
||||
cacheTimestamp = Date.now();
|
||||
return map;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[habbo-furnidata] Fetch error",
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
logger.warn("[habbo-furnidata] Fetch error", {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return cache || new Map();
|
||||
} finally {
|
||||
loading = null;
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
export type { HabboAssetBadge } from "@/types/furni";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { HabboAssetBadge } from "@/types/furni";
|
||||
import {
|
||||
habboExternalTextsUrl,
|
||||
type HabboGamedataHotel,
|
||||
habboExternalTextsUrl,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
import type { HabboAssetBadge } from "@/types/furni";
|
||||
|
||||
const BADGE_IMAGE_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -39,11 +39,7 @@ async function loadBadges(): Promise<{
|
||||
}> {
|
||||
const hotel = await getHabboGamedataHotel();
|
||||
const now = Date.now();
|
||||
if (
|
||||
badgeCache &&
|
||||
cacheHotel === hotel &&
|
||||
now - cacheTimestamp < CACHE_TTL
|
||||
) {
|
||||
if (badgeCache && cacheHotel === hotel && now - cacheTimestamp < CACHE_TTL) {
|
||||
return { badges: badgeCache, hotel };
|
||||
}
|
||||
if (loadingPromise) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -29,7 +29,10 @@ describe("moderation", () => {
|
||||
it("blocks text containing a filtered word", async () => {
|
||||
findMany.mockResolvedValue([{ word: "badword" }]);
|
||||
const result = await isAllowed("this contains badword here");
|
||||
expect(result).toEqual({ ok: false, reason: 'Blocked by word filter: "badword"' });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'Blocked by word filter: "badword"',
|
||||
});
|
||||
});
|
||||
|
||||
it("allows empty text", async () => {
|
||||
@@ -46,7 +49,9 @@ describe("moderation", () => {
|
||||
|
||||
it("moderateOrThrow throws on blocked content", async () => {
|
||||
findMany.mockResolvedValue([{ word: "bad" }]);
|
||||
await expect(moderateOrThrow("this is bad")).rejects.toThrow("Blocked by word filter");
|
||||
await expect(moderateOrThrow("this is bad")).rejects.toThrow(
|
||||
"Blocked by word filter",
|
||||
);
|
||||
});
|
||||
|
||||
it("moderateOrThrow resolves on clean content", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
env: {
|
||||
@@ -10,7 +10,12 @@ vi.mock("@/env", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { isPayPalConfigured, creditsPerUnit, PAYPAL_API, PAYPAL_CURRENCY } from "./paypal";
|
||||
import {
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
PAYPAL_API,
|
||||
PAYPAL_CURRENCY,
|
||||
} from "./paypal";
|
||||
|
||||
describe("paypal", () => {
|
||||
it("isPayPalConfigured returns true when credentials are set", () => {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import { env } from "@/env";
|
||||
|
||||
export const PAYPAL_API =
|
||||
env.PAYPAL_API?.replace(/\/+$/, "") ??
|
||||
"https://api-m.sandbox.paypal.com";
|
||||
env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
|
||||
|
||||
export const PAYPAL_CURRENCY = env.PAYPAL_CURRENCY.toUpperCase();
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getRankPermissionColumn,
|
||||
toPermissionValue,
|
||||
splitRankUpdateFields,
|
||||
RANK_GENERAL_FIELDS,
|
||||
splitRankUpdateFields,
|
||||
toPermissionValue,
|
||||
} from "./permission-ranks";
|
||||
|
||||
describe("getRankPermissionColumn", () => {
|
||||
|
||||
@@ -19,7 +19,12 @@ describe("sendCurrency", () => {
|
||||
giveDiamonds: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
};
|
||||
const result = await sendCurrency({ rcon, db: {} as any }, 1, "credits", 100);
|
||||
const result = await sendCurrency(
|
||||
{ rcon, db: {} as any },
|
||||
1,
|
||||
"credits",
|
||||
100,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
||||
});
|
||||
@@ -55,7 +60,12 @@ describe("sendCurrency", () => {
|
||||
giveDiamonds: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
};
|
||||
const result = await sendCurrency({ rcon, db: {} as any }, 1, "duckets", 50);
|
||||
const result = await sendCurrency(
|
||||
{ rcon, db: {} as any },
|
||||
1,
|
||||
"duckets",
|
||||
50,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(rcon.giveDuckets).toHaveBeenCalledWith(1, 50);
|
||||
});
|
||||
@@ -138,4 +148,4 @@ describe("sendCurrency", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
@@ -25,6 +26,7 @@ class SiteSettings {
|
||||
});
|
||||
return new Map(rows.map((r) => [r.key, r.value]));
|
||||
} catch {
|
||||
logger.warn("Failed to load site settings from database, using defaults");
|
||||
return new Map(Object.entries(DEFAULTS));
|
||||
}
|
||||
}
|
||||
@@ -38,7 +40,7 @@ class SiteSettings {
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch {
|
||||
// Redis unavailable — fall through
|
||||
logger.warn("Redis cache read failed for site settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ class SiteSettings {
|
||||
JSON.stringify(obj),
|
||||
);
|
||||
} catch {
|
||||
// non-critical
|
||||
logger.warn("Failed to write site settings to Redis cache");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +80,12 @@ class SiteSettings {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async getMany(
|
||||
keys: string[],
|
||||
): Promise<Record<string, string | null>> {
|
||||
async getMany(keys: string[]): Promise<Record<string, string | null>> {
|
||||
const map = await this.load();
|
||||
const result: Record<string, string | null> = {};
|
||||
for (const key of keys) {
|
||||
result[key] = map.get(key) ?? (key in DEFAULTS ? DEFAULTS[key] as string : null);
|
||||
result[key] =
|
||||
map.get(key) ?? (key in DEFAULTS ? (DEFAULTS[key] as string) : null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -111,7 +112,7 @@ class SiteSettings {
|
||||
try {
|
||||
await redis.del(REDIS_CACHE_KEY);
|
||||
} catch {
|
||||
// non-critical
|
||||
logger.warn("Failed to invalidate Redis cache for site settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const create = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
* then bundles everything into the .nitro format expected
|
||||
* by the Nitro HTML5 client.
|
||||
*/
|
||||
import { gunzipSync, inflateSync } from "node:zlib";
|
||||
import type {
|
||||
BatchConversionItem,
|
||||
BatchConversionResult,
|
||||
ConversionResult,
|
||||
} from "@/types/furni";
|
||||
import type { ConversionResult } from "@/types/furni";
|
||||
import {
|
||||
parseBitsJpeg2,
|
||||
parseBitsJpeg3,
|
||||
@@ -535,5 +530,3 @@ export function extractIconFromSwf(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ export interface UploadParams {
|
||||
customparams?: string;
|
||||
}
|
||||
|
||||
const MIGRATIONS_DIR = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "prisma/migrations");
|
||||
const MIGRATIONS_DIR = path.resolve(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"prisma/migrations",
|
||||
);
|
||||
|
||||
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
@@ -99,7 +102,10 @@ async function writeSqlMigration(params: {
|
||||
const padded = String(num).padStart(4, "0");
|
||||
const className = params.classname.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const fileName = `${padded}_import_furni_${className}.sql`;
|
||||
const filePath = path.join(/*turbopackIgnore: true*/ MIGRATIONS_DIR, fileName);
|
||||
const filePath = path.join(
|
||||
/*turbopackIgnore: true*/ MIGRATIONS_DIR,
|
||||
fileName,
|
||||
);
|
||||
|
||||
const lines: string[] = [
|
||||
`-- Migration: ${fileName}`,
|
||||
@@ -192,7 +198,10 @@ export async function uploadSingleFurni(params: {
|
||||
const { iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
|
||||
const nitroFileName = `${classname}.nitro`;
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, nitroFileName);
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
nitroFileName,
|
||||
);
|
||||
if (existsSync(nitroPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "../prisma";
|
||||
|
||||
export type { WebhookAction } from "@/types/admin";
|
||||
|
||||
@@ -65,13 +65,13 @@ describe("built-in theme presets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"#22c55e",
|
||||
"#ef4444",
|
||||
])("derives readable text for default button %s", (background) => {
|
||||
const foreground = readableColor("#ffffff", [background]);
|
||||
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
it.each(["#22c55e", "#ef4444"])(
|
||||
"derives readable text for default button %s",
|
||||
(background) => {
|
||||
const foreground = readableColor("#ffffff", [background]);
|
||||
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("readableColor", () => {
|
||||
@@ -101,25 +101,26 @@ describe("readableColor", () => {
|
||||
});
|
||||
|
||||
describe("deriveAdminPalette", () => {
|
||||
it.each(
|
||||
Object.entries(PRESETS),
|
||||
)("keeps admin text readable for %s", (_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const admin = deriveAdminPalette(palette);
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(
|
||||
contrastRatio(admin.accentForeground, admin.accent),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
it.each(Object.entries(PRESETS))(
|
||||
"keeps admin text readable for %s",
|
||||
(_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const admin = deriveAdminPalette(palette);
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(
|
||||
contrastRatio(admin.muted, admin.surface),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(admin.accentForeground, admin.accent),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not copy hostile public structural colors into admin surfaces", () => {
|
||||
const admin = deriveAdminPalette({
|
||||
@@ -153,9 +154,9 @@ describe("deriveAdminPalette", () => {
|
||||
expect(admin.canvas).toBe("#eef1f4");
|
||||
expect(admin.surface).toBe("#f5f7fa");
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(admin.sidebarText, admin.sidebar)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(
|
||||
contrastRatio(admin.sidebarText, admin.sidebar),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("ignores dark admin overrides while in light mode", () => {
|
||||
@@ -184,7 +185,9 @@ describe("deriveAdminPalette", () => {
|
||||
},
|
||||
);
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
@@ -249,19 +252,20 @@ describe("ensureReadableThemeColors", () => {
|
||||
});
|
||||
|
||||
describe("derivePublicForegrounds", () => {
|
||||
it.each(
|
||||
Object.entries(PRESETS),
|
||||
)("makes every semantic pair readable for %s", (_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const result = derivePublicForegrounds(palette);
|
||||
for (const pair of result.pairs) {
|
||||
expect(
|
||||
contrastRatio(pair.foreground, pair.background),
|
||||
pair.name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
it.each(Object.entries(PRESETS))(
|
||||
"makes every semantic pair readable for %s",
|
||||
(_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const result = derivePublicForegrounds(palette);
|
||||
for (const pair of result.pairs) {
|
||||
expect(
|
||||
contrastRatio(pair.foreground, pair.background),
|
||||
pair.name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("covers admin hierarchy and every semantic status", () => {
|
||||
const required = [
|
||||
@@ -286,11 +290,11 @@ describe("derivePublicForegrounds", () => {
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
expect(pair, `missing ${name}`).toBeDefined();
|
||||
const safePair = pair as NonNullable<typeof pair>;
|
||||
expect(
|
||||
contrastRatio(safePair.foreground, safePair.background),
|
||||
name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
const safePair = pair as NonNullable<typeof pair>;
|
||||
expect(
|
||||
contrastRatio(safePair.foreground, safePair.background),
|
||||
name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +416,7 @@ function completePalette(
|
||||
values.button_secondary_color ?? merged.color_accent;
|
||||
merged.button_outline_color =
|
||||
values.button_outline_color ?? merged.border_color;
|
||||
merged.panel_border_color =
|
||||
values.panel_border_color ?? merged.border_color;
|
||||
merged.panel_border_color = values.panel_border_color ?? merged.border_color;
|
||||
merged.gradient_from = values.gradient_from ?? merged.color_primary;
|
||||
merged.gradient_to = values.gradient_to ?? merged.color_accent;
|
||||
return merged;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createEventSchema,
|
||||
eventTypeSchema,
|
||||
} from "./event";
|
||||
import { createEventSchema, eventTypeSchema } from "./event";
|
||||
|
||||
describe("eventTypeSchema", () => {
|
||||
it("accepts valid type", () => {
|
||||
@@ -19,7 +16,9 @@ describe("eventTypeSchema", () => {
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
expect(eventTypeSchema.safeParse({ name: "", slug: "test" }).success).toBe(false);
|
||||
expect(eventTypeSchema.safeParse({ name: "", slug: "test" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(eventTypeSchema.safeParse({ slug: "test" }).success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -36,12 +35,10 @@ describe("eventTypeSchema", () => {
|
||||
|
||||
it("rejects slug with spaces", () => {
|
||||
expect(
|
||||
eventTypeSchema
|
||||
.safeParse({
|
||||
name: "Test",
|
||||
slug: "test event",
|
||||
})
|
||||
.success,
|
||||
eventTypeSchema.safeParse({
|
||||
name: "Test",
|
||||
slug: "test event",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -68,14 +65,12 @@ describe("createEventSchema", () => {
|
||||
|
||||
it("rejects missing title", () => {
|
||||
expect(
|
||||
createEventSchema
|
||||
.safeParse({
|
||||
title: "",
|
||||
description: "Has desc",
|
||||
typeId: 1,
|
||||
startsAt: "2025-07-25T10:00:00Z",
|
||||
})
|
||||
.success,
|
||||
createEventSchema.safeParse({
|
||||
title: "",
|
||||
description: "Has desc",
|
||||
typeId: 1,
|
||||
startsAt: "2025-07-25T10:00:00Z",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -102,4 +97,4 @@ describe("createEventSchema", () => {
|
||||
expect(result.data.isRecurring).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPollSchema,
|
||||
updatePollSchema,
|
||||
pollQuestionSchema,
|
||||
pollVoteSchema,
|
||||
updatePollSchema,
|
||||
voteOnPollSchema,
|
||||
} from "./poll";
|
||||
|
||||
@@ -61,17 +61,14 @@ describe("pollQuestionSchema", () => {
|
||||
|
||||
it("rejects empty question", () => {
|
||||
expect(
|
||||
pollQuestionSchema
|
||||
.safeParse({ pollId: 1, question: "", options: "Red" })
|
||||
pollQuestionSchema.safeParse({ pollId: 1, question: "", options: "Red" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing pollId", () => {
|
||||
expect(
|
||||
pollQuestionSchema
|
||||
.safeParse({ question: "Test?", options: "A" })
|
||||
.success,
|
||||
pollQuestionSchema.safeParse({ question: "Test?", options: "A" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -111,9 +108,9 @@ describe("voteOnPollSchema", () => {
|
||||
});
|
||||
|
||||
it("rejects empty votes array", () => {
|
||||
expect(
|
||||
voteOnPollSchema.safeParse({ pollId: 1, votes: [] }).success,
|
||||
).toBe(false);
|
||||
expect(voteOnPollSchema.safeParse({ pollId: 1, votes: [] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects too many votes (more than 50)", () => {
|
||||
@@ -121,9 +118,9 @@ describe("voteOnPollSchema", () => {
|
||||
questionId: i + 1,
|
||||
answer: `Answer ${i + 1}`,
|
||||
}));
|
||||
expect(
|
||||
voteOnPollSchema.safeParse({ pollId: 1, votes }).success,
|
||||
).toBe(false);
|
||||
expect(voteOnPollSchema.safeParse({ pollId: 1, votes }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,4 +134,4 @@ describe("updatePollSchema", () => {
|
||||
const result = updatePollSchema.safeParse({ title: "New Title" });
|
||||
if (result.success) expect(result.data.title).toBe("New Title");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,38 +27,26 @@ describe("updateRoomSchema", () => {
|
||||
});
|
||||
|
||||
it("rejects name too long", () => {
|
||||
expect(
|
||||
updateRoomSchema
|
||||
.safeParse({ name: "a".repeat(61) })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(updateRoomSchema.safeParse({ name: "a".repeat(61) }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects description too long", () => {
|
||||
expect(
|
||||
updateRoomSchema
|
||||
.safeParse({ description: "b".repeat(513) })
|
||||
.success,
|
||||
updateRoomSchema.safeParse({ description: "b".repeat(513) }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid state", () => {
|
||||
expect(
|
||||
updateRoomSchema
|
||||
.safeParse({ state: "banana" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(updateRoomSchema.safeParse({ state: "banana" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects usersMax below 1", () => {
|
||||
expect(
|
||||
updateRoomSchema.safeParse({ usersMax: 0 }).success,
|
||||
).toBe(false);
|
||||
expect(updateRoomSchema.safeParse({ usersMax: 0 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects usersMax above 100", () => {
|
||||
expect(
|
||||
updateRoomSchema.safeParse({ usersMax: 101 }).success,
|
||||
).toBe(false);
|
||||
expect(updateRoomSchema.safeParse({ usersMax: 101 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assignTicketSchema,
|
||||
createTicketSchema,
|
||||
replyTicketSchema,
|
||||
updateTicketStatusSchema,
|
||||
assignTicketSchema,
|
||||
updateTicketPrioritySchema,
|
||||
updateTicketStatusSchema,
|
||||
} from "./ticket";
|
||||
|
||||
describe("createTicketSchema", () => {
|
||||
@@ -20,36 +20,30 @@ describe("createTicketSchema", () => {
|
||||
|
||||
it("rejects short subject (less than 5 chars)", () => {
|
||||
expect(
|
||||
createTicketSchema
|
||||
.safeParse({
|
||||
subject: "Hi",
|
||||
category: "technical",
|
||||
message: "Help",
|
||||
})
|
||||
.success,
|
||||
createTicketSchema.safeParse({
|
||||
subject: "Hi",
|
||||
category: "technical",
|
||||
message: "Help",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects short message (less than 10 chars)", () => {
|
||||
expect(
|
||||
createTicketSchema
|
||||
.safeParse({
|
||||
subject: "Subject",
|
||||
category: "technical",
|
||||
message: "Hi",
|
||||
})
|
||||
.success,
|
||||
createTicketSchema.safeParse({
|
||||
subject: "Subject",
|
||||
category: "technical",
|
||||
message: "Hi",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing category", () => {
|
||||
expect(
|
||||
createTicketSchema
|
||||
.safeParse({
|
||||
subject: "Subject",
|
||||
message: "Some message",
|
||||
})
|
||||
.success,
|
||||
createTicketSchema.safeParse({
|
||||
subject: "Subject",
|
||||
message: "Some message",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -87,8 +81,7 @@ describe("updateTicketStatusSchema", () => {
|
||||
|
||||
it("rejects invalid status", () => {
|
||||
expect(
|
||||
updateTicketStatusSchema
|
||||
.safeParse({ ticketId: 1, status: "unknown" })
|
||||
updateTicketStatusSchema.safeParse({ ticketId: 1, status: "unknown" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -116,4 +109,4 @@ describe("updateTicketPrioritySchema", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createUserSchema,
|
||||
updateUserSchema,
|
||||
banUserSchema,
|
||||
createUserSchema,
|
||||
giveBadgeSchema,
|
||||
updateUserSchema,
|
||||
} from "./user";
|
||||
|
||||
describe("createUserSchema", () => {
|
||||
@@ -23,61 +23,51 @@ describe("createUserSchema", () => {
|
||||
|
||||
it("rejects short username (< 3 chars)", () => {
|
||||
expect(
|
||||
createUserSchema
|
||||
.safeParse({
|
||||
username: "ab",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
})
|
||||
.success,
|
||||
createUserSchema.safeParse({
|
||||
username: "ab",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects long username (> 20 chars)", () => {
|
||||
expect(
|
||||
createUserSchema
|
||||
.safeParse({
|
||||
username: "a".repeat(21),
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
})
|
||||
.success,
|
||||
createUserSchema.safeParse({
|
||||
username: "a".repeat(21),
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid username characters", () => {
|
||||
expect(
|
||||
createUserSchema
|
||||
.safeParse({
|
||||
username: "user@name",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
})
|
||||
.success,
|
||||
createUserSchema.safeParse({
|
||||
username: "user@name",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid email", () => {
|
||||
expect(
|
||||
createUserSchema
|
||||
.safeParse({
|
||||
username: "TestUser",
|
||||
mail: "not-an-email",
|
||||
password: "secret123",
|
||||
})
|
||||
.success,
|
||||
createUserSchema.safeParse({
|
||||
username: "TestUser",
|
||||
mail: "not-an-email",
|
||||
password: "secret123",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects short password (< 6 chars)", () => {
|
||||
expect(
|
||||
createUserSchema
|
||||
.safeParse({
|
||||
username: "TestUser",
|
||||
mail: "[email protected]",
|
||||
password: "12345",
|
||||
})
|
||||
.success,
|
||||
createUserSchema.safeParse({
|
||||
username: "TestUser",
|
||||
mail: "test@example.com",
|
||||
password: "12345",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -95,9 +85,7 @@ describe("updateUserSchema", () => {
|
||||
});
|
||||
|
||||
it("rejects rank out of range", () => {
|
||||
expect(
|
||||
updateUserSchema.safeParse({ rank: 8 }).success,
|
||||
).toBe(false);
|
||||
expect(updateUserSchema.safeParse({ rank: 8 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,16 +112,13 @@ describe("banUserSchema", () => {
|
||||
|
||||
it("rejects empty reason", () => {
|
||||
expect(
|
||||
banUserSchema
|
||||
.safeParse({ userId: 1, reason: "", duration: 1 })
|
||||
.success,
|
||||
banUserSchema.safeParse({ userId: 1, reason: "", duration: 1 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative duration", () => {
|
||||
expect(
|
||||
banUserSchema
|
||||
.safeParse({ userId: 1, reason: "Bad", duration: -1 })
|
||||
banUserSchema.safeParse({ userId: 1, reason: "Bad", duration: -1 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -153,4 +138,4 @@ describe("giveBadgeSchema", () => {
|
||||
giveBadgeSchema.safeParse({ userId: 1, badgeCode: "" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user