diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da220aa..998ba6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,8 +23,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm run typecheck - - name: Run Biome - run: pnpm exec biome ci . + - name: Run Vite+ checks + run: pnpm check - name: Build run: pnpm run build env: diff --git a/src/features/associations/association-card.tsx b/src/features/associations/association-card.tsx index c5d370d..ecea86f 100644 --- a/src/features/associations/association-card.tsx +++ b/src/features/associations/association-card.tsx @@ -20,12 +20,7 @@ import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" -import { - ASSOCIATION_LINK_FIELDS, - ASSOCIATION_LOGO_MAX_SIZE, - ASSOCIATION_LOGO_TYPES, - getAssociationInitials, -} from "./associations.constants" +import { ASSOCIATION_LINK_FIELDS, getAssociationInitials, validateAssociationLogo } from "./associations.constants" import type { Association, AssociationFormValues } from "./types" type AssociationCardProps = { @@ -85,13 +80,9 @@ export function AssociationCard({ function selectLogo(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) return - if (!ASSOCIATION_LOGO_TYPES.some((type) => type === file.type)) { - toast.error("Choose a JPG, PNG, or SVG logo.") - event.target.value = "" - return - } - if (file.size > ASSOCIATION_LOGO_MAX_SIZE) { - toast.error("The logo must be no larger than 1 MB.") + const error = validateAssociationLogo(file) + if (error) { + toast.error(error) event.target.value = "" return } diff --git a/src/features/associations/association-dialogs.tsx b/src/features/associations/association-dialogs.tsx index 168ecd2..ddfdef7 100644 --- a/src/features/associations/association-dialogs.tsx +++ b/src/features/associations/association-dialogs.tsx @@ -28,7 +28,7 @@ import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { errorHasCode } from "@/lib/errors" -import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES, getAssociationInitials } from "./associations.constants" +import { getAssociationInitials, validateAssociationLogo } from "./associations.constants" import { createAssociation, deleteAssociation, editAssociation } from "./associations.functions" import { associationSaveErrorMessage } from "./associations.validation" import type { Association } from "./types" @@ -74,11 +74,9 @@ export function AssociationDialog({ async function submit(event: React.FormEvent) { event.preventDefault() if (pending) return - if ( - logoFile && - (!ASSOCIATION_LOGO_TYPES.some((type) => type === logoFile.type) || logoFile.size > ASSOCIATION_LOGO_MAX_SIZE) - ) { - setError("Choose a JPG, PNG, or SVG logo no larger than 1 MB.") + const logoError = logoFile ? validateAssociationLogo(logoFile) : null + if (logoError) { + setError(logoError) return } diff --git a/src/features/associations/associations-page.tsx b/src/features/associations/associations-page.tsx index 9655ae2..f73d132 100644 --- a/src/features/associations/associations-page.tsx +++ b/src/features/associations/associations-page.tsx @@ -26,7 +26,6 @@ export function AssociationsPage({ loadedAssociations }: { loadedAssociations: A const [draftAssociationIds, setDraftAssociationIds] = useState>(new Set()) const [linksDialog, setLinksDialog] = useState(null) const draftAssociationIdsRef = useRef(draftAssociationIds) - draftAssociationIdsRef.current = draftAssociationIds useEffect(() => { setAssociations((current) => { @@ -60,6 +59,13 @@ export function AssociationsPage({ loadedAssociations }: { loadedAssociations: A setAssociations((current) => current.map((item) => (item.id === association.id ? association : item))) } + function removeDraftAssociationId(id: number) { + const nextDraftIds = new Set(draftAssociationIdsRef.current) + nextDraftIds.delete(id) + draftAssociationIdsRef.current = nextDraftIds + setDraftAssociationIds(nextDraftIds) + } + function addAssociation() { const draft: Association = { id: -Date.now(), @@ -70,21 +76,14 @@ export function AssociationsPage({ loadedAssociations }: { loadedAssociations: A links: { ...EMPTY_ASSOCIATION_LINKS }, } setAssociations((current) => [draft, ...current]) - setDraftAssociationIds((current) => { - const next = new Set(current).add(draft.id) - draftAssociationIdsRef.current = next - return next - }) + const nextDraftIds = new Set(draftAssociationIdsRef.current).add(draft.id) + draftAssociationIdsRef.current = nextDraftIds + setDraftAssociationIds(nextDraftIds) } function cancelDraft(id: number) { setAssociations((current) => current.filter((association) => association.id !== id)) - setDraftAssociationIds((current) => { - const next = new Set(current) - next.delete(id) - draftAssociationIdsRef.current = next - return next - }) + removeDraftAssociationId(id) } async function saveAssociation(id: number, values: AssociationFormValues) { @@ -100,14 +99,7 @@ export function AssociationsPage({ loadedAssociations }: { loadedAssociations: A try { const saved = draft ? await createAssociationFn({ data }) : await editAssociationFn({ data }) setAssociations((current) => current.map((association) => (association.id === id ? saved : association))) - if (draft) { - setDraftAssociationIds((current) => { - const next = new Set(current) - next.delete(id) - draftAssociationIdsRef.current = next - return next - }) - } + if (draft) removeDraftAssociationId(id) toast.success(`Association ${draft ? "created" : "updated"}`) void refresh() return true diff --git a/src/features/associations/associations.constants.ts b/src/features/associations/associations.constants.ts index 18a3b0c..179696e 100644 --- a/src/features/associations/associations.constants.ts +++ b/src/features/associations/associations.constants.ts @@ -5,6 +5,15 @@ import type { AssociationLink, AssociationLinks } from "./types" export const ASSOCIATION_LOGO_MAX_SIZE = 1024 * 1024 export const ASSOCIATION_LOGO_TYPES = ["image/jpeg", "image/png", "image/svg+xml"] as const +export function validateAssociationLogo(file: File) { + if (!ASSOCIATION_LOGO_TYPES.some((type) => type === file.type)) return "Choose a JPG, PNG, or SVG logo." + if (file.size > ASSOCIATION_LOGO_MAX_SIZE) { + const maximumMegabytes = ASSOCIATION_LOGO_MAX_SIZE / (1024 * 1024) + return `The logo must be no larger than ${maximumMegabytes} MB.` + } + return null +} + export const EMPTY_ASSOCIATION_LINKS: AssociationLinks = { email: null, website: null, diff --git a/src/features/associations/associations.validation.ts b/src/features/associations/associations.validation.ts index 4965f9c..873c45f 100644 --- a/src/features/associations/associations.validation.ts +++ b/src/features/associations/associations.validation.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { errorHasCode } from "../../lib/errors.ts" +import { errorHasCode, errorHasZodField } from "../../lib/errors.ts" import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES } from "./associations.constants.ts" const ALLOWED_LOGO_TYPES = new Set(ASSOCIATION_LOGO_TYPES) @@ -76,7 +76,8 @@ export function associationSaveErrorMessage(cause: unknown) { if ( errorHasCode(cause, "LOGO_TOO_LARGE") || errorHasCode(cause, "INVALID_LOGO_TYPE") || - errorHasCode(cause, "INVALID_FILE_TYPE") + errorHasCode(cause, "INVALID_FILE_TYPE") || + errorHasZodField(cause, "logo") ) { return "Choose a JPG, PNG, or SVG logo no larger than 1 MB." } diff --git a/src/features/projects/projects-page.tsx b/src/features/projects/projects-page.tsx index d46f63f..3027ebf 100644 --- a/src/features/projects/projects-page.tsx +++ b/src/features/projects/projects-page.tsx @@ -107,9 +107,10 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) const operation = reorderQueue.current.then(async () => { for (const projectIds of groups) await reorderProjectsFn({ data: { projectIds } }) }) - reorderQueue.current = operation.catch((error) => { - console.error(error) - }) + reorderQueue.current = operation.then( + () => undefined, + () => undefined + ) try { await operation diff --git a/src/features/telegram/leave-group-dialog.tsx b/src/features/telegram/leave-group-dialog.tsx index 4eeadb7..1aa9477 100644 --- a/src/features/telegram/leave-group-dialog.tsx +++ b/src/features/telegram/leave-group-dialog.tsx @@ -44,7 +44,12 @@ export function LeaveGroupDialog({ chatId, title }: { chatId: number; title: str toast.success(`Left ${title}.`) } setOpen(false) - await router.invalidate({ sync: true }) + try { + await router.invalidate({ sync: true }) + } catch (refreshError) { + console.error(refreshError) + toast.warning("The group was left, but the group list could not be refreshed.") + } } catch (error) { console.error(error) toast.error(errorMessage(error, "The group could not be left.")) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index eb5ceca..54210af 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -10,6 +10,19 @@ const errorRecordSchema = z.object({ cause: z.unknown().optional(), }) +const zodFieldErrorsSchema = z.object({ + data: z.object({ + zodError: z.object({ + properties: z.record( + z.string(), + z.object({ + errors: z.array(z.string()), + }) + ), + }), + }), +}) + function errorMessages(value: Value, depth: number): string[] { const message = z.string().safeParse(value) if (message.success) return [message.data] @@ -29,3 +42,19 @@ export function errorHasCode(cause: unknown, code: string) { export function errorMessage(cause: unknown, fallback: string) { return errorMessages(cause, 0).find((message) => message.trim()) ?? fallback } + +function hasZodField(cause: Value, field: string, depth: number): boolean { + const zodErrors = zodFieldErrorsSchema.safeParse(cause) + if (zodErrors.success && zodErrors.data.data.zodError.properties[field]?.errors.some((error) => error.trim())) { + return true + } + if (depth >= MAX_ERROR_DEPTH) return false + + const record = errorRecordSchema.safeParse(cause) + if (!record.success) return false + return hasZodField(record.data.error, field, depth + 1) || hasZodField(record.data.cause, field, depth + 1) +} + +export function errorHasZodField(cause: unknown, field: string) { + return hasZodField(cause, field, 0) +} diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 34d3fef..490c092 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -29,6 +29,47 @@ async function sourceFiles(directory) { return files.flat() } +function referencesSymbol(node, symbol, checker) { + let found = false + + function visit(current) { + if (ts.isIdentifier(current) && checker.getSymbolAtLocation(current) === symbol) { + found = true + return + } + if (!found) ts.forEachChild(current, visit) + } + + visit(node) + return found +} + +function handlerLogsError(root, errorParameter, checker) { + const errorSymbol = checker.getSymbolAtLocation(errorParameter) + if (!errorSymbol) return false + + let found = false + + function visit(node) { + if (node !== root && (ts.isFunctionLike(node) || ts.isCatchClause(node))) return + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "console" && + node.expression.name.text === "error" && + node.arguments.some((argument) => referencesSymbol(argument, errorSymbol, checker)) + ) { + found = true + return + } + if (!found) ts.forEachChild(node, visit) + } + + visit(root) + return found +} + function exportedServerFunctions(source, file) { const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) const serverFunctions = [] @@ -371,6 +412,17 @@ test("web content save errors explain actionable validation failures", () => { associationSaveErrorMessage(new Error("INVALID_LOGO_TYPE")), "Choose a JPG, PNG, or SVG logo no larger than 1 MB." ) + assert.equal( + associationSaveErrorMessage(new Error("INVALID_FILE_TYPE")), + "Choose a JPG, PNG, or SVG logo no larger than 1 MB." + ) + assert.equal( + associationSaveErrorMessage({ + message: "Input validation failed", + data: { zodError: { properties: { logo: { errors: ["Too big: expected value to be <= 1048576"] } } } }, + }), + "Choose a JPG, PNG, or SVG logo no larger than 1 MB." + ) }) test("project and association mutations forward FormData to the backend", async () => { @@ -392,16 +444,28 @@ test("project and association mutations forward FormData to the backend", async test("every caught runtime error is written to the console", async () => { const srcDirectory = new URL("../src", import.meta.url).pathname const failures = [] + const files = await sourceFiles(srcDirectory) + const program = ts.createProgram(files, { + allowJs: true, + jsx: ts.JsxEmit.Preserve, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + target: ts.ScriptTarget.Latest, + }) + const checker = program.getTypeChecker() - for (const file of await sourceFiles(srcDirectory)) { - const source = await readFile(file, "utf8") - const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) + for (const file of files) { + const sourceFile = program.getSourceFile(file) + assert.ok(sourceFile, `TypeScript must load ${file}`) function visit(node) { if (ts.isCatchClause(node)) { - const errorName = node.variableDeclaration?.name.getText(sourceFile) - const body = node.block.getText(sourceFile) - const logsCaughtError = errorName && new RegExp(`console\\.error\\([^)]*\\b${errorName}\\b`).test(body) + const errorParameter = node.variableDeclaration?.name + const logsCaughtError = + errorParameter && ts.isIdentifier(errorParameter) + ? handlerLogsError(node.block, errorParameter, checker) + : false if (!logsCaughtError) { const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) @@ -415,12 +479,17 @@ test("every caught runtime error is written to the console", async () => { node.expression.name.text === "catch" ) { const handler = node.arguments[0] - const errorName = + const errorParameter = handler && (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) - ? handler.parameters[0]?.name.getText(sourceFile) + ? handler.parameters[0]?.name : undefined - const body = handler?.getText(sourceFile) ?? "" - const logsCaughtError = errorName && new RegExp(`console\\.error\\([^)]*\\b${errorName}\\b`).test(body) + const logsCaughtError = + errorParameter && + ts.isIdentifier(errorParameter) && + handler && + (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) + ? handlerLogsError(handler.body, errorParameter, checker) + : false if (!logsCaughtError) { const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))