Harden CMS security and theme contrast
This commit is contained in:
1 parent
2465ff2170
commit
4a1e1115b3
57 files changed
+1023
-231
No files matched your search
+10
-6
@@ -1,6 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
import { resolveStaffUser } from "@/lib/admin/staff-user";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
@@ -25,12 +27,14 @@ export async function requireStaff(): Promise<StaffUser> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
const minRank = await getMinStaffRank();
|
||||
if (!isStaff(session.user.rank, minRank)) redirect("/");
|
||||
return {
|
||||
id: Number(session.user.id),
|
||||
rank: session.user.rank,
|
||||
username: session.user.name ?? "",
|
||||
};
|
||||
const staff = await resolveStaffUser(session.user.id, minRank, (id) =>
|
||||
prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, rank: true, username: true },
|
||||
}),
|
||||
);
|
||||
if (!staff) redirect("/");
|
||||
return staff;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminMutationNotice } from "@/lib/admin/notice";
|
||||
|
||||
describe("adminMutationNotice", () => {
|
||||
it("maps a successful redirect to a safe notice", () => {
|
||||
expect(adminMutationNotice({ saved: "1" })).toEqual({
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an error code without reflecting arbitrary query text", () => {
|
||||
expect(adminMutationNotice({ error: "<script>" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no mutation result", () => {
|
||||
expect(adminMutationNotice({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface AdminMutationNotice {
|
||||
tone: "ok" | "danger";
|
||||
label: "Saved" | "Error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function adminMutationNotice(params: {
|
||||
saved?: string;
|
||||
error?: string;
|
||||
}): AdminMutationNotice | null {
|
||||
if (params.error) {
|
||||
return {
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
};
|
||||
}
|
||||
if (params.saved === "1") {
|
||||
return {
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStaffUser } from "@/lib/admin/staff-user";
|
||||
|
||||
describe("resolveStaffUser", () => {
|
||||
it("rejects a session whose database rank has been revoked", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
|
||||
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns the current database identity instead of stale JWT values", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
|
||||
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
|
||||
id: 7,
|
||||
rank: 8,
|
||||
username: "CurrentName",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
|
||||
export interface StaffUserRecord {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export type FindStaffUser = (id: number) => Promise<StaffUserRecord | null>;
|
||||
|
||||
export async function resolveStaffUser(
|
||||
sessionUserId: string,
|
||||
minStaffRank: number,
|
||||
findUser: FindStaffUser,
|
||||
): Promise<StaffUserRecord | null> {
|
||||
const id = Number(sessionUserId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
|
||||
const user = await findUser(id);
|
||||
return user && isStaff(user.rank, minStaffRank) ? user : null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
|
||||
describe("personalTokenScope", () => {
|
||||
it("scopes token operations to the user model and owner id", () => {
|
||||
expect(personalTokenScope(42)).toEqual({
|
||||
tokenableId: 42n,
|
||||
tokenableType: "App\\Models\\User",
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
-5
@@ -1,5 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { personalTokenScope, USER_TOKENABLE_TYPE } from "@/lib/auth/personal-token-scope";
|
||||
import { databaseUserId } from "@/lib/auth/session-user";
|
||||
|
||||
/**
|
||||
* Bearer-token auth for the public REST API, backed by personal_access_tokens
|
||||
@@ -7,8 +9,6 @@ import { prisma } from "@/lib/prisma";
|
||||
* stored as the sha256 of the plaintext; the client sends the plaintext (or the
|
||||
* Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`.
|
||||
*/
|
||||
const TOKENABLE_TYPE = "App\\Models\\User";
|
||||
|
||||
function hashToken(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export async function bearerUserId(req: Request): Promise<number | null> {
|
||||
prisma.personalAccessTokens
|
||||
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() }, select: { id: true } })
|
||||
.catch(() => {});
|
||||
return Number(row.tokenableId);
|
||||
return databaseUserId(row.tokenableId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -48,8 +48,7 @@ export async function issueToken(userId: number, name = "api"): Promise<string |
|
||||
try {
|
||||
await prisma.personalAccessTokens.create({
|
||||
data: {
|
||||
tokenableId: BigInt(userId),
|
||||
tokenableType: TOKENABLE_TYPE,
|
||||
...personalTokenScope(userId),
|
||||
name: name.slice(0, 100),
|
||||
token: hashToken(plaintext),
|
||||
abilities: '["*"]',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiUnavailable, pagination, positiveBigInt } from "@/lib/api";
|
||||
|
||||
describe("apiUnavailable", () => {
|
||||
it("returns a no-store 503 error without exposing internal details", async () => {
|
||||
const response = apiUnavailable("Account data is temporarily unavailable");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Account data is temporarily unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("positiveBigInt", () => {
|
||||
it.each(["1", "9007199254740993"])("parses positive integer id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBe(BigInt(raw));
|
||||
});
|
||||
|
||||
it.each([null, "", "0", "-1", "1.5", "abc"])("rejects invalid id %s", (raw) => {
|
||||
expect(positiveBigInt(raw)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
it("rejects fractional and malformed values before they reach Prisma", () => {
|
||||
const params = new URLSearchParams({ page: "2.5", perPage: "10.1" });
|
||||
|
||||
expect(pagination(params, 20, 100)).toEqual({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
skip: 0,
|
||||
take: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps valid page sizes to the configured maximum", () => {
|
||||
const params = new URLSearchParams({ page: "3", perPage: "999" });
|
||||
|
||||
expect(pagination(params, 20, 100)).toEqual({
|
||||
page: 3,
|
||||
perPage: 100,
|
||||
skip: 200,
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
-5
@@ -25,12 +25,30 @@ export function apiError(message: string, status = 400): NextResponse {
|
||||
return apiJson({ error: message }, { status });
|
||||
}
|
||||
|
||||
/** Safe response for temporary infrastructure failures. */
|
||||
export function apiUnavailable(message = "Service temporarily unavailable"): NextResponse {
|
||||
return apiError(message, 503);
|
||||
}
|
||||
|
||||
/** Parse an unsigned, non-zero database identifier without Number precision loss. */
|
||||
export function positiveBigInt(raw: string | null): bigint | null {
|
||||
if (!raw || !/^\d+$/.test(raw)) return null;
|
||||
const value = BigInt(raw);
|
||||
return value > 0n ? value : null;
|
||||
}
|
||||
|
||||
/** Clamp a ?page / ?perPage pair from search params. */
|
||||
export function pagination(searchParams: URLSearchParams, defaultPer = 20, maxPer = 100) {
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
const perPage = Math.min(
|
||||
maxPer,
|
||||
Math.max(1, Number(searchParams.get("perPage") ?? defaultPer) || defaultPer),
|
||||
);
|
||||
const requestedPage = positiveInteger(searchParams.get("page"), 1);
|
||||
const requestedPerPage = positiveInteger(searchParams.get("perPage"), defaultPer);
|
||||
const perPage = Math.min(maxPer, requestedPerPage);
|
||||
const maxSafePage = Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1;
|
||||
const page = Math.min(requestedPage, maxSafePage);
|
||||
return { page, perPage, skip: (page - 1) * perPage, take: perPage };
|
||||
}
|
||||
|
||||
function positiveInteger(raw: string | null, fallback: number): number {
|
||||
if (!raw || !/^\d+$/.test(raw)) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const USER_TOKENABLE_TYPE = "App\\Models\\User";
|
||||
|
||||
export function personalTokenScope(userId: number) {
|
||||
return {
|
||||
tokenableId: BigInt(userId),
|
||||
tokenableType: USER_TOKENABLE_TYPE,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { databaseUserId, sessionUserId } from "@/lib/auth/session-user";
|
||||
|
||||
describe("sessionUserId", () => {
|
||||
it("returns a positive safe integer from a valid session id", () => {
|
||||
expect(sessionUserId("42")).toBe(42);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "0", "-1", "1.5", "abc", Number.MAX_SAFE_INTEGER + 1])(
|
||||
"rejects invalid session id %s",
|
||||
(value) => {
|
||||
expect(sessionUserId(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("databaseUserId", () => {
|
||||
it("converts a safe positive database id", () => {
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export function sessionUserId(value: unknown): number | null {
|
||||
const id = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export function databaseUserId(value: bigint): number | null {
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
describe("formPositiveBigInt", () => {
|
||||
it("parses a positive identifier from FormData", () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", "42");
|
||||
|
||||
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);
|
||||
|
||||
expect(formPositiveBigInt(formData, "id")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
|
||||
export function formPositiveBigInt(formData: FormData, field: string): bigint | null {
|
||||
const value = formData.get(field);
|
||||
return typeof value === "string" ? positiveBigInt(value.trim()) : null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serverErrorRecord } from "@/lib/server-log";
|
||||
|
||||
describe("serverErrorRecord", () => {
|
||||
it("keeps operational context while redacting sensitive fields", () => {
|
||||
expect(serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
|
||||
orderId: "ORDER-123",
|
||||
userId: 42,
|
||||
authorization: "Bearer secret",
|
||||
apiToken: "secret-token",
|
||||
})).toEqual({
|
||||
level: "error",
|
||||
event: "paypal.capture_failed",
|
||||
message: "gateway timeout",
|
||||
context: {
|
||||
orderId: "ORDER-123",
|
||||
userId: 42,
|
||||
authorization: "[REDACTED]",
|
||||
apiToken: "[REDACTED]",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes non-Error failures without serializing arbitrary objects", () => {
|
||||
expect(serverErrorRecord("admin.update_failed", { password: "secret" })).toMatchObject({
|
||||
message: "Unknown server error",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
type LogScalar = string | number | boolean | null;
|
||||
type LogContext = Record<string, unknown>;
|
||||
|
||||
const SENSITIVE_KEY = /(authorization|cookie|password|secret|token|api[-_]?key)/i;
|
||||
|
||||
export interface ServerErrorRecord {
|
||||
level: "error";
|
||||
event: string;
|
||||
message: string;
|
||||
context: Record<string, LogScalar>;
|
||||
}
|
||||
|
||||
export function serverErrorRecord(
|
||||
event: string,
|
||||
error: unknown,
|
||||
context: LogContext = {},
|
||||
): ServerErrorRecord {
|
||||
return {
|
||||
level: "error",
|
||||
event,
|
||||
message: error instanceof Error ? error.message.slice(0, 500) : "Unknown server error",
|
||||
context: Object.fromEntries(Object.entries(context).map(([key, value]) => [
|
||||
key,
|
||||
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
export function logServerError(event: string, error: unknown, context: LogContext = {}): void {
|
||||
console.error(JSON.stringify(serverErrorRecord(event, error, context)));
|
||||
}
|
||||
|
||||
function logScalar(value: unknown): LogScalar {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? value
|
||||
: value === null
|
||||
? null
|
||||
: "[NON_SCALAR]";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authorizeTopupCapture,
|
||||
claimTopupDelivery,
|
||||
recordCreatedTopup,
|
||||
} from "@/lib/services/paypal-topup";
|
||||
|
||||
describe("authorizeTopupCapture", () => {
|
||||
it("rejects an order created by a different user", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 41,
|
||||
status: "CREATED",
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
|
||||
code: "ORDER_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an order that has already left the created state", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
|
||||
code: "ORDER_ALREADY_PROCESSED",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes credit delivery for a captured order without capturing it again", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
amount: 12.5,
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).resolves.toMatchObject({
|
||||
action: "DELIVER_CREDITS",
|
||||
amount: 12.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordCreatedTopup", () => {
|
||||
it("stores the PayPal order id with its owner before approval", async () => {
|
||||
let saved: Parameters<typeof record>[0] | undefined;
|
||||
const record = async (data: {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) => {
|
||||
saved = data;
|
||||
};
|
||||
|
||||
await recordCreatedTopup(
|
||||
{ userId: 99, orderId: "ORDER-123", amount: 12.5, currency: "EUR", credits: 1250 },
|
||||
record,
|
||||
);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
userId: 99,
|
||||
transactionId: "ORDER-123",
|
||||
status: "CREATED",
|
||||
amount: 12.5,
|
||||
currency: "EUR",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimTopupDelivery", () => {
|
||||
it("rejects delivery when another request already claimed the order", async () => {
|
||||
const claim = async () => ({ count: 0 });
|
||||
|
||||
await expect(claimTopupDelivery(claim)).rejects.toMatchObject({
|
||||
code: "DELIVERY_ALREADY_CLAIMED",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows delivery after atomically claiming the pending order", async () => {
|
||||
const claim = async () => ({ count: 1 });
|
||||
|
||||
await expect(claimTopupDelivery(claim)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface PendingTopup {
|
||||
userId: number;
|
||||
status: string | null;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export type AuthorizedTopup = PendingTopup & { action: "CAPTURE" | "DELIVER_CREDITS" };
|
||||
|
||||
export type FindTopupOrder = (orderId: string) => Promise<PendingTopup | null>;
|
||||
|
||||
export interface CreatedTopupRecord {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: "CREATED";
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type CreateTopupRecord = (data: CreatedTopupRecord) => Promise<unknown>;
|
||||
|
||||
export class TopupCaptureError extends Error {
|
||||
constructor(public readonly code: "ORDER_NOT_FOUND" | "ORDER_ALREADY_PROCESSED") {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export class TopupDeliveryError extends Error {
|
||||
readonly code = "DELIVERY_ALREADY_CLAIMED";
|
||||
|
||||
constructor() {
|
||||
super("DELIVERY_ALREADY_CLAIMED");
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimTopupDelivery(
|
||||
claim: () => Promise<{ count: number }>,
|
||||
): Promise<void> {
|
||||
const result = await claim();
|
||||
if (result.count !== 1) throw new TopupDeliveryError();
|
||||
}
|
||||
|
||||
export async function authorizeTopupCapture(
|
||||
userId: number,
|
||||
orderId: string,
|
||||
findOrder: FindTopupOrder,
|
||||
): Promise<AuthorizedTopup> {
|
||||
const order = await findOrder(orderId);
|
||||
if (!order || order.userId !== userId) {
|
||||
throw new TopupCaptureError("ORDER_NOT_FOUND");
|
||||
}
|
||||
if (order.status === "CAPTURED_PENDING_CREDIT" && Number.isFinite(order.amount)) {
|
||||
return { ...order, action: "DELIVER_CREDITS" };
|
||||
}
|
||||
if (order.status !== "CREATED") {
|
||||
throw new TopupCaptureError("ORDER_ALREADY_PROCESSED");
|
||||
}
|
||||
return { ...order, action: "CAPTURE" };
|
||||
}
|
||||
|
||||
export async function recordCreatedTopup(
|
||||
topup: {
|
||||
userId: number;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
},
|
||||
createRecord: CreateTopupRecord,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await createRecord({
|
||||
userId: topup.userId,
|
||||
transactionId: topup.orderId,
|
||||
status: "CREATED",
|
||||
description: `Top-up: ${topup.credits} credits`,
|
||||
amount: topup.amount,
|
||||
currency: topup.currency,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
describe("createOwnedTicketReply", () => {
|
||||
it("does not create a reply for a ticket owned by another user", async () => {
|
||||
let created = false;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 7 }),
|
||||
createReply: async () => {
|
||||
created = true;
|
||||
throw new Error("must not run");
|
||||
},
|
||||
touchTicket: async () => undefined,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createOwnedTicketReply(db, { ticketId: 5n, userId: 99, content: "Help" }),
|
||||
).resolves.toBeNull();
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it("creates the reply and updates ticket activity as one operation", async () => {
|
||||
let touchedAt: Date | undefined;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 99 }),
|
||||
createReply: async (data: { createdAt: Date }) => ({
|
||||
id: 8n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
createdAt: data.createdAt,
|
||||
}),
|
||||
touchTicket: async (_ticketId: bigint, updatedAt: Date) => {
|
||||
touchedAt = updatedAt;
|
||||
},
|
||||
};
|
||||
|
||||
const reply = await createOwnedTicketReply(db, {
|
||||
ticketId: 5n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({ id: 8n, userId: 99, content: "Help" });
|
||||
expect(touchedAt).toBe(reply?.createdAt);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface TicketReplyRecord {
|
||||
id: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
export interface TicketReplyDb {
|
||||
findTicket(ticketId: bigint): Promise<{ id: bigint; userId: number | null } | null>;
|
||||
createReply(data: {
|
||||
ticketId: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Promise<TicketReplyRecord>;
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function createOwnedTicketReply(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
): Promise<TicketReplyRecord | null> {
|
||||
const ticket = await db.findTicket(input.ticketId);
|
||||
if (!ticket || ticket.userId !== input.userId) return null;
|
||||
|
||||
const now = new Date();
|
||||
const reply = await db.createReply({ ...input, createdAt: now, updatedAt: now });
|
||||
await db.touchTicket(input.ticketId, now);
|
||||
return reply;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
|
||||
import { PRESETS } from "@/lib/theme-presets";
|
||||
|
||||
describe("contrastRatio", () => {
|
||||
it("calculates the WCAG ratio for black on white", () => {
|
||||
expect(contrastRatio("#000000", "#ffffff")).toBe(21);
|
||||
});
|
||||
|
||||
it("returns null for unsupported color formats", () => {
|
||||
expect(contrastRatio("var(--color-text)", "#ffffff")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in theme presets", () => {
|
||||
it("derives WCAG-readable public foregrounds for every palette", () => {
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
const backgrounds = [preset.color_background, preset.color_surface];
|
||||
const foregrounds = [
|
||||
readableColor(preset.color_text, backgrounds),
|
||||
readableColor(preset.color_text_muted, backgrounds),
|
||||
readableColor(preset.color_primary, backgrounds),
|
||||
readableColor(preset.color_accent, backgrounds),
|
||||
];
|
||||
|
||||
for (const foreground of foregrounds) {
|
||||
for (const background of backgrounds) {
|
||||
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = readableColor(preset.button_text_color, [preset.button_primary_color]);
|
||||
expect(contrastRatio(buttonText, preset.button_primary_color)).toBeGreaterThanOrEqual(4.5);
|
||||
|
||||
const primaryForeground = readableColor(preset.button_text_color, [preset.color_primary]);
|
||||
expect(contrastRatio(primaryForeground, preset.color_primary)).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", () => {
|
||||
it("keeps the preferred color when it passes on every background", () => {
|
||||
expect(readableColor("#0f172a", ["#ffffff", "#f8fafc"], 4.5)).toBe("#0f172a");
|
||||
});
|
||||
|
||||
it("replaces low-contrast golden text with a readable neutral", () => {
|
||||
const color = readableColor("#f59e0b", ["#ffffff", "#f8fafc"], 4.5);
|
||||
|
||||
expect(color).toBe("#000000");
|
||||
expect(contrastRatio(color, "#ffffff")).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(color, "#f8fafc")).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("chooses white text for a dark button when configured text fails", () => {
|
||||
expect(readableColor("#777777", ["#111827"], 4.5)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("preserves unsupported custom colors instead of corrupting CSS", () => {
|
||||
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
type Rgb = readonly [number, number, number];
|
||||
|
||||
export function contrastRatio(foreground: string, background: string): number | null {
|
||||
const fg = parseHex(foreground);
|
||||
const bg = parseHex(background);
|
||||
if (!fg || !bg) return null;
|
||||
|
||||
const light = Math.max(relativeLuminance(fg), relativeLuminance(bg));
|
||||
const dark = Math.min(relativeLuminance(fg), relativeLuminance(bg));
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
export function readableColor(
|
||||
preferred: string,
|
||||
backgrounds: readonly string[],
|
||||
minimumRatio = 4.5,
|
||||
): string {
|
||||
const preferredRatios = backgrounds.map((background) => contrastRatio(preferred, background));
|
||||
if (preferredRatios.some((ratio) => ratio === null)) return preferred;
|
||||
if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio)) return preferred;
|
||||
|
||||
const candidates = ["#000000", "#ffffff"] as const;
|
||||
return candidates
|
||||
.map((color) => ({
|
||||
color,
|
||||
minimum: Math.min(...backgrounds.map((background) => contrastRatio(color, background) ?? 0)),
|
||||
}))
|
||||
.sort((a, b) => b.minimum - a.minimum)[0].color;
|
||||
}
|
||||
|
||||
function parseHex(value: string): Rgb | null {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return [
|
||||
Number.parseInt(match[1].slice(0, 2), 16),
|
||||
Number.parseInt(match[1].slice(2, 4), 16),
|
||||
Number.parseInt(match[1].slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function relativeLuminance(rgb: Rgb): number {
|
||||
const [red, green, blue] = rgb.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
Reference in new issue
Block a user