From 0bd05d6e0b668feb676bfb8e125d9b5d57fe2c2b Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Fri, 16 Oct 2026 13:29:32 +0500 Subject: [PATCH] Validate and deduplicate custom difficulty presets --- components/StartGame.tsx | 49 +++-- lib/api.ts | 18 +- lib/service-error.ts | 24 ++- package.json | 1 + scripts/test-difficulty-presets-sql.mjs | 181 ++++++++++++++++++ shared/difficulty-presets.ts | 13 ++ shared/protocol.ts | 6 +- ...1016064302_fix_difficulty_preset_names.sql | 32 ++++ tests/difficulty-presets.test.ts | 86 +++++++++ tests/service-error.test.ts | 58 +++++- 10 files changed, 427 insertions(+), 41 deletions(-) create mode 100644 scripts/test-difficulty-presets-sql.mjs create mode 100644 shared/difficulty-presets.ts create mode 100644 supabase/migrations/20261016064302_fix_difficulty_preset_names.sql create mode 100644 tests/difficulty-presets.test.ts diff --git a/components/StartGame.tsx b/components/StartGame.tsx index 301a8d1..f1a20f3 100644 --- a/components/StartGame.tsx +++ b/components/StartGame.tsx @@ -7,9 +7,11 @@ import { useRouter } from "next/navigation"; import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react"; import { difficulties, + customDifficultySchema, type DifficultyPreset, } from "@/shared/protocol"; import { api } from "@/lib/client-api"; +import { mergeDifficultyPresets } from "@/shared/difficulty-presets"; export { api } from "@/lib/client-api"; export function StartGame({ datasetId, @@ -32,13 +34,19 @@ export function StartGame({ [error, setError] = useState(""); const router = useRouter(); const starting = useRef(false); + const savingPreset = useRef(false); useEffect(() => { let disposed = false; api<{ presets: DifficultyPreset[] }>("difficulty-presets") .then(({ presets }) => { - if (!disposed) setCustomPresets(presets); + if (!disposed) + setCustomPresets((current) => + mergeDifficultyPresets(current, presets), + ); }) - .catch(() => {}); + .catch((failure) => { + if (!disposed) setError(failure.message); + }); return () => { disposed = true; }; @@ -54,19 +62,25 @@ export function StartGame({ ]; async function saveDifficulty(event: FormEvent) { event.preventDefault(); - if (savingDifficulty) return; + if (savingPreset.current || starting.current) return; + const parsed = customDifficultySchema.safeParse({ + name: difficultyName, + seconds: difficultySeconds, + zoom: difficultyZoom, + }); + if (!parsed.success) { + setError(parsed.error.issues[0].message); + return; + } + savingPreset.current = true; setSavingDifficulty(true); setError(""); try { const { preset } = await api<{ preset: DifficultyPreset }>( "difficulty-presets", - { - name: difficultyName.trim(), - seconds: difficultySeconds, - zoom: difficultyZoom, - }, + parsed.data, ); - setCustomPresets((current) => [...current, preset]); + setCustomPresets((current) => mergeDifficultyPresets(current, [preset])); setDifficulty(preset.id); setDifficultyName(""); setDifficultySeconds(90); @@ -75,11 +89,12 @@ export function StartGame({ } catch (failure) { setError((failure as Error).message); } finally { + savingPreset.current = false; setSavingDifficulty(false); } } async function start() { - if (starting.current) return; + if (starting.current || savingPreset.current) return; starting.current = true; setLoading(true); setError(""); @@ -168,17 +183,17 @@ export function StartGame({ setDifficultyName(event.target.value)} + onChange={(event) => + setDifficultyName([...event.target.value].slice(0, 40).join("")) + } minLength={2} - maxLength={40} + maxLength={80} required autoComplete="off" placeholder={t("Name this difficulty")} disabled={savingDifficulty || loading} /> - +
{loading ? t("Preparing your world…") diff --git a/lib/api.ts b/lib/api.ts index 0e46c73..5f1d171 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -13,7 +13,11 @@ import { viewpointSchema, } from "../shared/protocol"; import { mediaUrl, mediaUrls } from "./catalog"; -import { ServiceError, checkServiceError } from "./service-error"; +import { + ServiceError, + checkServiceError, + checkDifficultyPresetError, +} from "./service-error"; import { requestOrigin, verifyOrigin } from "./request-origin"; import { browserAccount } from "./request-security"; import { jsonBody } from "./json-body"; @@ -165,12 +169,7 @@ export async function handle(request: NextRequest, path: string[]) { .insert(body) .select("id,name,seconds,zoom") .single(); - if (error?.code === "23505") - throw new ServiceError( - "A difficulty with that name already exists", - 409, - ); - checkServiceError(error); + checkDifficultyPresetError(error); return NextResponse.json({ preset: data }); } } @@ -202,10 +201,7 @@ export async function handle(request: NextRequest, path: string[]) { const body = z .object({ datasetId: idSchema, - difficulty: z.union([ - z.enum(["easy", "normal", "hard"]), - idSchema, - ]), + difficulty: z.union([z.enum(["easy", "normal", "hard"]), idSchema]), }) .parse(await jsonBody(request)); const secret = cookieSecret(request) || randomBytes(32).toString("hex"); diff --git a/lib/service-error.ts b/lib/service-error.ts index ec99dac..83ddc7f 100644 --- a/lib/service-error.ts +++ b/lib/service-error.ts @@ -7,10 +7,28 @@ export class ServiceError extends Error { } } -export function checkServiceError(error: { message: string; code?: string } | null) { +export function checkDifficultyPresetError( + error: { message: string; code?: string } | null, +) { + if (error?.code === "23505") + throw new ServiceError("A difficulty with that name already exists", 409); + if (error?.code === "23514") + throw new ServiceError( + "Use a name with 2–40 characters and a round time of 15–600 seconds in 15-second steps", + 400, + ); + checkServiceError(error); +} + +export function checkServiceError( + error: { message: string; code?: string } | null, +) { if (!error) return; if (error.code === "P0001") throw new Error(error.message); - if (error.code === "PGRST116") throw new ServiceError("Record not found", 404); + if (error.code === "PGRST116") + throw new ServiceError("Record not found", 404); console.error("Supabase request failed:", error.code, error.message); - throw new ServiceError("MineGeo could not reach its data service. Please try again."); + throw new ServiceError( + "MineGeo could not reach its data service. Please try again.", + ); } diff --git a/package.json b/package.json index 0ce4ef4..de382ca 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:leaderboard": "node scripts/test-leaderboard-sql.mjs", "test:multiplayer": "node scripts/test-multiplayer-sql.mjs", "test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs", + "test:difficulty-presets": "node scripts/test-difficulty-presets-sql.mjs", "test:renderer": "node scripts/test-renderer.mjs", "render:map": "node scripts/render-map.mjs", "test:production": "node scripts/test-production.mjs", diff --git a/scripts/test-difficulty-presets-sql.mjs b/scripts/test-difficulty-presets-sql.mjs new file mode 100644 index 0000000..d4fff7e --- /dev/null +++ b/scripts/test-difficulty-presets-sql.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js"; + +const db = new PGlite(); +try { + await db.exec(`create role anon; create role authenticated; create role service_role; + create schema auth; create table auth.users(id uuid primary key); + create function auth.uid() returns uuid language sql as $$ select null::uuid $$; + create schema storage; + create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]); + create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text); + create function storage.foldername(text) returns text[] language sql as $$ select string_to_array($1, '/') $$;`); + for (const file of (await readdir("supabase/migrations")) + .filter((f) => f.endsWith(".sql")) + .sort()) { + if (file.endsWith("fix_difficulty_preset_names.sql")) + await assert.rejects( + db.query( + "insert into public.difficulty_presets(name,seconds,zoom) values('Normal',90,true)", + ), + (error) => error.code === "23514", + ); + await db.exec(await readFile(`supabase/migrations/${file}`, "utf8")); + } + const valid = [ + "Normal", + "Speed 90", + "Hard", + "Тест", + "中文", + "😎😎", + "😎".repeat(40), + "a".repeat(40), + "a\\b", + ]; + for (const name of valid) + await db.query( + "insert into public.difficulty_presets(name,seconds,zoom) values($1,90,true)", + [name], + ); + for (const name of [ + "", + "a", + "😎", + "a".repeat(41), + "😎".repeat(41), + " leading", + "trailing ", + "x\nname", + "x\u0085name", + "x\u202ename", + "x\u2066name", + ]) + await assert.rejects( + db.query( + "insert into public.difficulty_presets(name,seconds,zoom) values($1,90,true)", + [name], + ), + (error) => error.code === "23514", + ); + for (const seconds of [0, 14, 16, 601]) + await assert.rejects( + db.query( + "insert into public.difficulty_presets(name,seconds,zoom) values('Invalid clock',$1,true)", + [seconds], + ), + (error) => error.code === "23514", + ); + await assert.rejects( + db.query( + "insert into public.difficulty_presets(name,seconds,zoom) values('normal',90,true)", + ), + (error) => error.code === "23505", + ); + + const preset = ( + await db.query( + "insert into public.difficulty_presets(name,seconds,zoom) values('Playable preset 45',45,false) returning id", + ) + ).rows[0].id; + for (const [id, seconds] of [ + ["easy", 180], + ["normal", 90], + ["hard", 30], + ]) + assert.equal( + ( + await db.query("select public.resolve_difficulty_preset($1) preset", [ + id, + ]) + ).rows[0].preset.seconds, + seconds, + ); + await assert.rejects( + db.query( + "select public.resolve_difficulty_preset('00000000-0000-4000-8000-000000000000')", + ), + /Choose a saved difficulty/, + ); + assert.equal( + ( + await db.query("select public.resolve_difficulty_preset($1) preset", [ + preset.toUpperCase(), + ]) + ).rows[0].preset.id, + preset, + ); + const owner = ( + await db.query( + "insert into auth.users values(gen_random_uuid()) returning id", + ) + ).rows[0].id; + const map = ( + await db.query( + `insert into public.datasets(owner_id,name,source,bounds,state,panorama_count) + values($1,'Difficulty fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`, + [owner], + ) + ).rows[0].id; + const manifest = { + viewpoints: Array.from({ length: 5 }, (_, i) => ({ + id: `p${i}`, + x: i, + y: 65, + z: i, + })), + }; + const revision = ( + await db.query( + "insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id", + [map, JSON.stringify(manifest)], + ) + ).rows[0].id; + await db.query("update public.datasets set revision_id=$1 where id=$2", [ + revision, + map, + ]); + const solo = ( + await db.query("select public.create_game($1,$2,$3,$4) id", [ + map, + preset, + owner, + "a".repeat(64), + ]) + ).rows[0].id; + const multi = ( + await db.query("select public.create_map_room($1,$2,'Host',2,$3,$4) id", [ + map, + preset, + "b".repeat(64), + owner, + ]) + ).rows[0].id; + for (const [table, id] of [ + ["game_sessions", solo], + ["multiplayer_rooms", multi], + ]) + assert.deepEqual( + ( + await db.query( + `select difficulty_settings from public.${table} where id=$1`, + [id], + ) + ).rows[0].difficulty_settings, + { id: preset, name: "Playable preset 45", seconds: 45, zoom: false }, + ); + assert.equal( + ( + await db.query( + "select has_table_privilege('anon','public.difficulty_presets','INSERT') allowed", + ) + ).rows[0].allowed, + false, + ); + console.log( + "Difficulty SQL regressions passed: original failure reproduced, normal/Unicode names, unsafe names, duplicates, time limits, solo/multiplayer rules and permissions.", + ); +} finally { + await db.close(); +} diff --git a/shared/difficulty-presets.ts b/shared/difficulty-presets.ts new file mode 100644 index 0000000..2d99923 --- /dev/null +++ b/shared/difficulty-presets.ts @@ -0,0 +1,13 @@ +import type { DifficultyPreset } from "./protocol"; + +// A list fetched before a save must not remove the newly saved preset. +export function mergeDifficultyPresets( + current: DifficultyPreset[], + incoming: DifficultyPreset[], +) { + return [ + ...new Map( + [...current, ...incoming].map((preset) => [preset.id, preset]), + ).values(), + ].sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index 28217de..61383fb 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -194,8 +194,10 @@ export const customDifficultySchema = z name: z .string() .trim() - .min(2) - .max(40) + .refine( + (name) => [...name].length >= 2 && [...name].length <= 40, + "Difficulty names must contain 2–40 characters", + ) .refine( (name) => !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test( diff --git a/supabase/migrations/20261016064302_fix_difficulty_preset_names.sql b/supabase/migrations/20261016064302_fix_difficulty_preset_names.sql new file mode 100644 index 0000000..69a5845 --- /dev/null +++ b/supabase/migrations/20261016064302_fix_difficulty_preset_names.sql @@ -0,0 +1,32 @@ +-- The original U& expression doubled its Unicode escapes, so the regex +-- interpreted code-point text as a character range and rejected normal names. +alter table public.difficulty_presets + drop constraint difficulty_presets_name_check; +alter table public.difficulty_presets + add constraint difficulty_presets_name_check check ( + name = btrim(name) + and length(name) between 2 and 40 + and name !~ U&'[\0001-\001F\007F-\009F\202A-\202E\2066-\2069]' + ); + +-- A PL/pgSQL CASE without ELSE raises case_not_found for every saved UUID, +-- before reaching the custom-preset lookup below it. +create or replace function public.resolve_difficulty_preset(p_id text) +returns jsonb language plpgsql stable security invoker set search_path = '' as $$ +declare preset jsonb; +begin + case p_id + when 'easy' then + return jsonb_build_object('id', 'easy', 'name', 'Easy', 'seconds', 180, 'zoom', true); + when 'normal' then + return jsonb_build_object('id', 'normal', 'name', 'Normal', 'seconds', 90, 'zoom', true); + when 'hard' then + return jsonb_build_object('id', 'hard', 'name', 'Hard', 'seconds', 30, 'zoom', false); + else null; + end case; + select jsonb_build_object('id', p.id::text, 'name', p.name, 'seconds', p.seconds, 'zoom', p.zoom) + into preset from public.difficulty_presets p where p.id::text = lower(p_id); + if not found then raise exception 'Choose a saved difficulty'; end if; + return preset; +end; +$$; diff --git a/tests/difficulty-presets.test.ts b/tests/difficulty-presets.test.ts new file mode 100644 index 0000000..2fb967d --- /dev/null +++ b/tests/difficulty-presets.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { customDifficultySchema } from "../shared/protocol"; +import { mergeDifficultyPresets } from "../shared/difficulty-presets"; + +const rules = { seconds: 90, zoom: true }; +test("difficulty names accept normal text and count Unicode characters consistently with PostgreSQL", () => { + for (const name of [ + "Normal", + "Speed 90", + "Тест", + "中文", + "😎😎", + "😎".repeat(40), + "a".repeat(40), + "a\\b", + ]) + assert.equal( + customDifficultySchema.safeParse({ name, ...rules }).success, + true, + name, + ); + assert.equal( + customDifficultySchema.parse({ name: " My challenge ", ...rules }).name, + "My challenge", + ); + for (const name of [ + "", + "a", + "😎", + "a".repeat(41), + "😎".repeat(41), + "x\nname", + "x\u0085name", + "x\u202ename", + "x\u2066name", + ]) + assert.equal( + customDifficultySchema.safeParse({ name, ...rules }).success, + false, + ); +}); + +test("difficulty rules enforce their supported range and strict request shape", () => { + for (const seconds of [15, 90, 600]) + assert.equal( + customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds }) + .success, + true, + ); + for (const seconds of [0, 14, 16, 601, 15.5, NaN, Infinity, "90"]) + assert.equal( + customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds }) + .success, + false, + ); + assert.equal( + customDifficultySchema.safeParse({ + name: "Fixture", + ...rules, + owner: "forged", + }).success, + false, + ); +}); + +test("a delayed preset listing cannot erase a newly saved preset or duplicate it", async () => { + const old = { id: "old", name: "Old", ...rules }; + const saved = { id: "saved", name: "New", ...rules }; + let release!: (presets: (typeof old)[]) => void; + const response = new Promise<(typeof old)[]>((resolve) => { + release = resolve; + }); + let presets = [old]; + const loading = response.then((snapshot) => { + presets = mergeDifficultyPresets(presets, snapshot); + }); + presets = mergeDifficultyPresets(presets, [saved]); + release([old]); + await loading; + assert.deepEqual( + presets.map((p) => p.id), + ["saved", "old"], + ); + assert.equal(mergeDifficultyPresets(presets, [saved]).length, 2); +}); diff --git a/tests/service-error.test.ts b/tests/service-error.test.ts index 08b5371..4fbca0a 100644 --- a/tests/service-error.test.ts +++ b/tests/service-error.test.ts @@ -1,18 +1,58 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { checkServiceError, ServiceError } from "../lib/service-error"; +import { + checkServiceError, + checkDifficultyPresetError, + ServiceError, +} from "../lib/service-error"; + +test("expected preset validation and duplicate-name errors return 400/409 without database log noise", (t) => { + const log = t.mock.method(console, "error", () => {}); + for (const [code, status] of [ + ["23514", 400], + ["23505", 409], + ] as const) + assert.throws( + () => + checkDifficultyPresetError({ + code, + message: "private database constraint details", + }), + (error) => { + assert.ok(error instanceof ServiceError); + assert.equal(error.status, status); + assert.doesNotMatch(error.message, /private database|constraint/); + return true; + }, + ); + assert.equal(log.mock.callCount(), 0); + assert.doesNotThrow(() => checkDifficultyPresetError(null)); +}); test("backend failures use 503 and keep database internals out of browser errors", (t) => { t.mock.method(console, "error", () => {}); - assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => { - assert.ok(error instanceof ServiceError); - assert.equal(error.status, 503); - assert.doesNotMatch(error.message, /private_table|42P01/); - return true; - }); + assert.throws( + () => + checkServiceError({ + code: "42P01", + message: 'relation "private_table" does not exist', + }), + (error) => { + assert.ok(error instanceof ServiceError); + assert.equal(error.status, 503); + assert.doesNotMatch(error.message, /private_table|42P01/); + return true; + }, + ); }); test("missing records and expected RPC validation keep actionable statuses and messages", () => { - assert.throws(() => checkServiceError({ code: "PGRST116", message: "no rows" }), (error) => error instanceof ServiceError && error.status === 404); - assert.throws(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/); + assert.throws( + () => checkServiceError({ code: "PGRST116", message: "no rows" }), + (error) => error instanceof ServiceError && error.status === 404, + ); + assert.throws( + () => checkServiceError({ code: "P0001", message: "Guess outside map" }), + /Guess outside map/, + ); assert.doesNotThrow(() => checkServiceError(null)); });