diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 76a3ccd..17802a4 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -8,3 +8,4 @@ Dated, one-line summaries of each autonomous security-hardening round. - 2026-08-05 — Round 4: fixed 7 confirmed defects — (1) an entity `rating`/`averageRating` of `null` no longer crashes the whole collection load (`isStructuredRating` is null- and type-safe, `normaliseRating`/`formatRating` coerce malformed values, and the load treats `null` as "no rating"); (2) a relationship `target` or entity field named after an `Object.prototype` member (e.g. `"toString"`) no longer resolves to an inherited function and throws during load (`resolveReference` and the implicit-relationship path use `Object.hasOwn` guards); (3) a non-array `videos` field no longer throws via `.map()`; (4) a non-string resolved platform `title` no longer throws via `.replace()`; (5) `detectNumericFields` runs a single pass over each card's own fields instead of a keys×cards nested scan, so a hostile collection with many uniquely-named fields can no longer freeze the main thread for tens of seconds; (6) `SourceIcon` detects known sources by URL hostname (anchored on the dot boundary) rather than a substring of the whole URL, so an attacker link can no longer be branded with a trusted source's icon/name via a path like `evil.example/en.wikipedia.org/x`; (7) the pre-commit PII and British-English gates read staged paths NUL-terminated with `core.quotePath=false` and fail closed on unreadable blobs, closing a silent bypass for files whose names contain non-ASCII/backslash/quote/newline characters. 24 reproduction tests added. Deferred (confirmed but out of scope for a smallest-diff round): collection `forced` settings persist into global state and are never reverted — the correct fix is a collection-scoped overlay plus a persisted-store migration, carrying moderate/high regression risk. Refuted: stale `dist/` analyser artefact leak (publish root is a clean Cloudflare Pages checkout with `ANALYZE` off). - 2026-08-05 — Round 5: fixed 4 confirmed defects — (1) `normaliseDetailUrls` guards each array element with `isDetailLink` before reading `.url`, so an entity `detailUrls: [null]` (an unvalidated `.loose()` passthrough) no longer throws and rejects the whole collection load; (2) entity files load through a fixed-size concurrency pool with a generous id cap (`ENTITY_FETCH_CONCURRENCY`/`MAX_ENTITY_IDS`) instead of a `Promise.all` over the whole untrusted `index.json`, closing a main-thread stall / CDN request-amplification DoS from a hostile index listing tens of thousands of ids; (3) Snap Ranking's `initGame` refuses to start above `MAX_UNIQUE_VALUES` distinct values (one guess button is rendered per value; the default per-card `order` field on a large collection otherwise renders tens of thousands of DOM buttons and freezes the tab); (4) the "Hard Reset" clears every `itemdeck-`-prefixed localStorage key and all three IndexedDB databases (app DB, the idb-keyval cached-collection store, and the plugin cache DB) via `clearAllPersistedData`, honouring the dialog's "delete everything" promise instead of leaving cached remote collections, imported data, config and game state behind. 11 reproduction tests added. Deferred (confirmed, own dedicated round): collection `forced` settings still persist into global state without a revert path — the smallest safe fix is a symmetric backup/restore mirroring `_mechanicOverridesBackup` (snapshot the touched keys on apply, restore on source change, without clearing `appliedCollectionDefaultsSourceId`). Nitpicks/defence-in-depth noted (not fixed): `fontUrl`/`cardBackBackgroundImage` import fields use bare `z.url()` (unreachable — no consumer), `validateForcedSettings` enum allow-lists are stale, `fieldDiscovery` bracket lookups lack `Object.hasOwn` (latent — provider unmounted), image fetch has no size/timeout pre-check, and `cardBackBackground`/`usePlaceholderImages` are absent from `partialize`. - 2026-08-06 — Round 6: fixed 7 confirmed defects — (1) collection `forced` settings now snapshot the user's own displaced values per source and restore them when the active source changes, with crash recovery via `onRehydrateStorage` (mirroring `_mechanicOverridesBackup`); previously viewing one hostile allowlisted-CDN source once, reachable via a single unconfirmed `/gh//` link, permanently overwrote the visitor's global display config with no revert path (`clearCollectionForcedSettings` had zero callers) — the dead action is repurposed into `restoreCollectionForcedSettings`; (2) untrusted entity text fields (`summary`, resolved platform `title`/`shortTitle`/`summary`/`year`) are coerced to strings at the `useCollection` source instead of cast, so an object-typed field (e.g. an i18n `{en: "…"}` value) can no longer reach JSX as a child and crash the whole collection view — the device badge sink fires on first paint in the default grid; (3) `computeCollectionStats` computes min/max in a single pass instead of spreading a per-card array into `Math.min`/`Math.max`, so a large untrusted collection no longer throws `RangeError` in `CollectionToast`, which renders outside the collection error boundary and would blank the app; (4) `loadCollection` caps the untrusted `entityTypes` count (`MAX_ENTITY_TYPES`) and loads types through a fixed-size pool, closing a per-type fetch multiplier that amplified one collection load into ~80k CDN requests plus GitHub-quota exhaustion; (5) `useMyPlausibleMeDiscovery` caps discovered collections (`MAX_DISCOVERED_COLLECTIONS`) and pools metadata fetches, closing a zero-click CDN request flood triggerable via an attacker `/gh//` URL on the startup picker; (6) `card.imageUrls` (images + videos) is capped per card (`MAX_MEDIA_PER_CARD`), bounding the gallery dot buttons and the load-time image preloader against an entity listing a huge media array; (7) the hard reset runs its three IndexedDB cleanups independently via `Promise.allSettled` and `deleteDB` gained an `onblocked` handler, so a blocked or failed app-DB delete no longer hangs the dialog or silently leaves cached collections and the plugin DB on disk while reporting a complete "delete everything". 19 reproduction tests added. Refuted: `applyMechanicOverrides` second-activation backup clobber (unreachable — the Start Game overlay is gated on `!activeMechanic`, so overrides only apply when no mechanic is active and every deactivation restores first) and the write-only `collectionForcedSettings` mid-session revert (TanStack Query structural sharing keeps `data.settings` identity stable, so the apply effect does not re-fire; folded into the S1 fix). Nitpicks noted (not fixed): TruffleHog pre-commit `--since-commit HEAD` empty-range scan, gitleaks checksum not provenance-anchored, `build:analyse` writing `stats.html` into the deploy root, forced `cardBackStyle`/`titleDisplayMode` enum mismatch vs the store types, and the `getDB()` in-flight dedup that makes the delete-blocked path reachable. +- 2026-08-07 — Round 7: fixed 11 confirmed defects — (1) `settings.json` `maxVisibleCards` tested its lower bound before flooring and had no finiteness check, so `0.5` stored `0` and `1e400` stored `Infinity` (which serialises to `null`), either of which makes `CardGrid` discard every card on flip and persists globally beyond the collection that supplied it; bounded after the floor and capped at the settings-panel maximum. (2) `settings.json` `searchFields` was type-filtered but uncapped, and search resolves every field on every card per settled query; capped at 32. (3) `DetailLink.source`/`label` were never type-checked (the entity schema is loose and only the URL was validated), so a non-string `source` threw while the sources overlay lowercased it and an object-valued `source`/`label` reached JSX as a child, replacing the whole card grid with the error boundary two clicks after load; both coerced at the normalisation choke point. (4) duplicate entity ids survived the loader and defeated `CardGrid`'s random-selection guard, which proves "every selected id still exists" by comparing counts — a duplicate makes the count match while an id is absent, throwing during render; duplicates also produced duplicate React keys in every view and made one flip toggle several cards. Ids and single-file entity arrays are now deduplicated at load, which also drops the redundant fetch per repeat. (5) quiz wrong-answer selection scanned the correct-answer array linearly per candidate value, and both dimensions are collection-sized, so a 7.4MB payload froze the main thread for 168s and the emitted question carried one rendered option per alternative; matched against a `Set` and capped the offered alternatives, in `relationshipToName` and `fillTheBlank`. (6) Snap Ranking built its card-value map as a plain object literal, so a card id of `"__proto__"` stored nothing (the inherited setter ignores primitives) and the `undefined` guard read back `Object.prototype` — the card was dealt but could never be scored, and rendered as `[object Object]`. (7) the relationship resolver rebuilt the entire relationship record for every entity in both the resolve and the rank pass, with neither the record nor the entity list capped, so cost grew with the square of the payload (10000 relationships × 1000 entities: 11.9s, now 44ms); relationships are grouped by entity type once in `createResolverContext`. (8) image preloading had no aggregate cap and probed the cache one URL at a time, emitting its first progress tick only after the whole probe loop, and the loading overlay clears only at 100% with no skip while the active source persists — a lockout across reloads rather than a slow load; the list is capped and the cached URL set read once. (9) `imageCache.set` recomputed the cache totals with a full store scan, so caching N images cost N(N+1)/2 record reads and eviction added a second scan; admission, eviction and the write now happen in one transaction over both stores (800 stores: 5.0s to under 0.4s). The first pre-merge review caught that an intermediate version still read the totals in a snapshot taken before the write transaction opened, so the preloader's five concurrent writes each judged admission from the same stale state — reproduced as a budget overshoot to 140% of the maximum. (10) the filter dropdown mounts one checkbox per option and re-reconciles the whole list on every toggle, and `genres` is an uncapped per-entity array while platform and year were bounded only by the 10000-entity loader cap, so no field had a usable ceiling; all three are capped where the lists are assembled, since many entities contributing a few values each reach the same total. (11) a stored "never cache" preference only suppressed the consent prompt while preloading still fetched and persisted every image, making "Never cache" weaker than declining once; caching permission is now a rule separate from whether to ask. 41 reproduction tests added (944 → 985). Deferred (confirmed, own dedicated round): entity edits are stored in one flat source-unscoped map, so switching collections bleeds one collection's private notes onto another's cards and silently overwrites them — refuted as a security finding (no exfiltration path) but a real data-integrity defect, and every correct fix changes the persisted shape of a store that has no `version` or `migrate`, plus a product decision about provenance-less existing edits. Also deferred: "Import Collection" writes an unvalidated, unbounded blob to a localStorage key nothing reads, so the feature is inert while reporting success — the correct fix is to remove the button or implement the feature, both product calls. Refuted: forced `cardBackStyle`/`titleDisplayMode` enum mismatch re-raised as substantive (the claimed CSS impact is false — the `--card-title-*` custom properties are defined but never read, and `cardBackStyle` has no renderer at all; it remains the round-6 nitpick). Logged for a future round (confirmed by a pre-merge reviewer, not fixed here): `evictLRU` does not persist corrected totals when its scan fallback runs but nothing needs evicting, so a poisoned metadata record would survive — unreachable today because the method has no callers; and `readTotals` heals downward drift only, so its comment claims more than it delivers. Dependency review: `npm audit --omit=dev` reports 0 advisories in the production tree; all 25 advisories are devDependency-only and no dependency was changed. diff --git a/src/components/CardGrid/CardGrid.tsx b/src/components/CardGrid/CardGrid.tsx index 5685691..0763bf4 100644 --- a/src/components/CardGrid/CardGrid.tsx +++ b/src/components/CardGrid/CardGrid.tsx @@ -16,6 +16,7 @@ import { useSettingsStore, CARD_ASPECT_RATIOS } from "@/stores/settingsStore"; import { useMechanicContext, useMechanicCardActions } from "@/mechanics"; import { createFieldSortComparator, resolveFieldPath } from "@/utils/fieldPathResolver"; import { shuffle } from "@/utils/shuffle"; +import { capFilterOptions } from "@/utils/filterOptions"; import { LoadingSkeleton } from "@/components/LoadingSkeleton"; import { springPresets, getItemDelay } from "@/config/animationPresets"; import type { CardDisplayConfig } from "@/types/display"; @@ -616,10 +617,16 @@ export function CardGrid() { } } + // The filter dropdown mounts one checkbox per option for every field and + // re-reconciles the whole list on each toggle. `genres` is an uncapped + // per-entity array, and the card-derived fields are only bounded by the + // loader's entity cap, so all three need a ceiling. Cap the assembled + // lists rather than the per-entity arrays: many entities contributing a + // few values each reach the same total. return { - platforms: Array.from(platforms).sort(), - years: Array.from(years).sort((a, b) => b - a), - genres: Array.from(genres).sort(), + platforms: capFilterOptions(Array.from(platforms).sort(), "platform"), + years: capFilterOptions(Array.from(years).sort((a, b) => b - a), "year"), + genres: capFilterOptions(Array.from(genres).sort(), "genres"), }; }, [sourceCards]); diff --git a/src/components/LoadingScreen/LoadingScreen.tsx b/src/components/LoadingScreen/LoadingScreen.tsx index 0803dfc..f5265da 100644 --- a/src/components/LoadingScreen/LoadingScreen.tsx +++ b/src/components/LoadingScreen/LoadingScreen.tsx @@ -15,6 +15,7 @@ import { collectionKeys } from "@/hooks/queryKeys"; import { useCollectionData } from "@/context/CollectionDataContext"; import { useImagePreloader } from "@/hooks/useImageCache"; import { useSettingsStore } from "@/stores/settingsStore"; +import { mayCacheCollection } from "@/utils/cacheConsent"; import { useSourceStore } from "@/stores/sourceStore"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; import { isCollectionCached, listCachedCollections, type CacheInfo } from "@/lib/cardCache"; @@ -152,6 +153,21 @@ export function LoadingScreen({ return !hasCacheConsent(activeSourceId); }, [activeSourceId, activeSource?.isBuiltIn, cacheConsentPreference, hasCacheConsent]); + // Whether caching is actually permitted, which is a different question from + // whether to ask. A stored "never" answers the prompt permanently but must + // still forbid caching: preloading writes every image to IndexedDB, so + // gating only the prompt made "Never cache" weaker than declining once. + const mayCacheImages = useMemo( + () => + mayCacheCollection({ + hasActiveSource: Boolean(activeSourceId), + isBuiltIn: Boolean(activeSource?.isBuiltIn), + preference: cacheConsentPreference, + hasSourceConsent: activeSourceId ? hasCacheConsent(activeSourceId) : false, + }), + [activeSourceId, activeSource?.isBuiltIn, cacheConsentPreference, hasCacheConsent] + ); + // Start image preloading; a failed preload must not strand the // loading screen, so advance to complete on rejection const startImagePreload = useCallback(() => { @@ -197,13 +213,13 @@ export function LoadingScreen({ if (needsCacheConsent) { setPhase("consent"); setConsentDialogOpen(true); - } else if (shouldPreloadImages && imageUrls.length > 0) { + } else if (mayCacheImages && shouldPreloadImages && imageUrls.length > 0) { startImagePreload(); } else { setPhase("complete"); } } - }, [isLoadingCollection, error, activeSourceId, phase, shouldPreloadImages, imageUrls, startImagePreload, needsCacheConsent]); + }, [isLoadingCollection, error, activeSourceId, phase, shouldPreloadImages, imageUrls, startImagePreload, needsCacheConsent, mayCacheImages]); // Handle image preloading complete useEffect(() => { diff --git a/src/loaders/collectionLoader.ts b/src/loaders/collectionLoader.ts index 9a79299..682efc8 100644 --- a/src/loaders/collectionLoader.ts +++ b/src/loaders/collectionLoader.ts @@ -89,6 +89,40 @@ export async function loadCollectionDefinition( return validateCollectionDefinition(data); } +/** + * Remove repeated ids, keeping the first occurrence and the listed order. + * + * Entity ids come from an untrusted index and are used as React keys and as + * the identity CardGrid's random selection is validated against. That guard + * proves "every selected id still exists" by comparing counts, so a + * duplicate lets the count match while an id is absent and the render + * throws. Duplicates also make one flip toggle several cards and cost a + * redundant fetch each. + */ +function dedupeIds(ids: string[]): string[] { + return ids.length > 1 ? Array.from(new Set(ids)) : ids; +} + +/** + * Remove entities repeating an id already seen, keeping the first. + * + * The single-file array formats bypass the index path, so they need the same + * uniqueness guarantee as dedupeIds gives the index. + */ +function dedupeEntitiesById(entities: Entity[]): Entity[] { + if (entities.length < 2) { + return entities; + } + const seen = new Set(); + return entities.filter((entity) => { + if (seen.has(entity.id)) { + return false; + } + seen.add(entity.id); + return true; + }); +} + /** * Extract entity IDs from index file data. * @@ -103,7 +137,7 @@ export async function loadCollectionDefinition( function extractEntityIds(data: unknown, pluralType: string): string[] { // Format 1: Direct array if (Array.isArray(data)) { - return data.filter((id): id is string => typeof id === "string"); + return dedupeIds(data.filter((id): id is string => typeof id === "string")); } // Format 2: Object with entity type key @@ -112,24 +146,30 @@ function extractEntityIds(data: unknown, pluralType: string): string[] { // Try plural form key (e.g., "adverts") if (pluralType in record && Array.isArray(record[pluralType])) { - return (record[pluralType] as unknown[]).filter( - (id): id is string => typeof id === "string" + return dedupeIds( + (record[pluralType] as unknown[]).filter( + (id): id is string => typeof id === "string" + ) ); } // Try singular form key (e.g., "advert") - just in case const singularType = pluralType.replace(/s$/, ""); if (singularType in record && Array.isArray(record[singularType])) { - return (record[singularType] as unknown[]).filter( - (id): id is string => typeof id === "string" + return dedupeIds( + (record[singularType] as unknown[]).filter( + (id): id is string => typeof id === "string" + ) ); } // Try common keys like "items", "entities", "ids" for (const key of ["items", "entities", "ids"]) { if (key in record && Array.isArray(record[key])) { - return (record[key] as unknown[]).filter( - (id): id is string => typeof id === "string" + return dedupeIds( + (record[key] as unknown[]).filter( + (id): id is string => typeof id === "string" + ) ); } } @@ -230,11 +270,13 @@ export async function loadEntities( const data = (await response.json()) as unknown; if (Array.isArray(data)) { - return data - .map((item, index) => - parseEntityTolerant(item, `${pluralFileUrl}[${String(index)}]`) - ) - .filter((entity): entity is Entity => entity !== null); + return dedupeEntitiesById( + data + .map((item, index) => + parseEntityTolerant(item, `${pluralFileUrl}[${String(index)}]`) + ) + .filter((entity): entity is Entity => entity !== null) + ); } const single = parseEntityTolerant(data, pluralFileUrl); @@ -257,11 +299,13 @@ export async function loadEntities( const data = (await response.json()) as unknown; if (Array.isArray(data)) { - return data - .map((item, index) => - parseEntityTolerant(item, `${singleFileUrl}[${String(index)}]`) - ) - .filter((entity): entity is Entity => entity !== null); + return dedupeEntitiesById( + data + .map((item, index) => + parseEntityTolerant(item, `${singleFileUrl}[${String(index)}]`) + ) + .filter((entity): entity is Entity => entity !== null) + ); } // Single entity in file diff --git a/src/loaders/relationshipResolver.ts b/src/loaders/relationshipResolver.ts index 98b9f04..6b9d323 100644 --- a/src/loaders/relationshipResolver.ts +++ b/src/loaders/relationshipResolver.ts @@ -23,6 +23,16 @@ export interface ResolverContext { /** Entity lookup maps for fast access */ entityMaps: Record>; + + /** + * Relationship definitions grouped by the entity type they apply to. + * + * Both the relationship record and the entity list come from untrusted + * collection JSON and neither is capped, so rebuilding the entries for + * every entity multiplies the two dimensions and makes the load cost grow + * with the square of the payload. Grouping once keeps it linear. + */ + relationshipsByType: Map; } /** @@ -47,10 +57,34 @@ export function createResolverContext( entityMaps[type] = map; } + // Group relationships by the entity type in their "type.field" key once, + // so neither the resolve nor the rank loop rescans the whole record per + // entity. + const relationshipsByType = new Map< + string, + [string, RelationshipDefinition][] + >(); + + for (const [relKey, relDef] of Object.entries( + definition.relationships ?? {} + )) { + const [relType, fieldName] = relKey.split("."); + if (!relType || !fieldName) { + continue; + } + const forType = relationshipsByType.get(relType); + if (forType) { + forType.push([fieldName, relDef]); + } else { + relationshipsByType.set(relType, [[fieldName, relDef]]); + } + } + return { definition, entities, entityMaps, + relationshipsByType, }; } @@ -94,17 +128,12 @@ export function resolveEntityRelationships( context: ResolverContext ): ResolvedEntity { const resolved: Record = {}; - const relationships = context.definition.relationships ?? {}; - - // Find relationships that apply to this entity type - for (const [relKey, relDef] of Object.entries(relationships)) { - // Relationship keys are in format "entityType.fieldName" - const [relType, fieldName] = relKey.split("."); - - if (relType !== entityType || !fieldName) { - continue; - } + // Relationships are pre-grouped by entity type, so only the entries that + // apply to this type are visited. + for (const [fieldName, relDef] of context.relationshipsByType.get( + entityType + ) ?? []) { const fieldValue = entity[fieldName]; if (fieldValue === undefined) { @@ -198,20 +227,14 @@ export function getEntityRank( entityType: string, context: ResolverContext ): number | null { - const relationships = context.definition.relationships ?? {}; - - // Look for ordinal relationship - for (const [relKey, relDef] of Object.entries(relationships)) { + // Look for ordinal relationship among this type's relationships only + for (const [fieldName, relDef] of context.relationshipsByType.get( + entityType + ) ?? []) { if (relDef.type !== "ordinal") { continue; } - const [relType, fieldName] = relKey.split("."); - - if (relType !== entityType || !fieldName) { - continue; - } - const rankValue = entity[fieldName]; if (typeof rankValue === "number") { diff --git a/src/loaders/settingsLoader.ts b/src/loaders/settingsLoader.ts index 51d1f9a..49305a6 100644 --- a/src/loaders/settingsLoader.ts +++ b/src/loaders/settingsLoader.ts @@ -13,6 +13,30 @@ import type { import { COLLECTION_SETTINGS_VERSION } from "@/types/collectionSettings"; import { isAllowedCollectionSource } from "@/config/allowedSources"; +/** + * Upper bound on a collection's suggested `maxVisibleCards`. + * + * `settings.json` is untrusted and this loader is the only ingress for the + * key that lacks the positive-integer bound every sibling schema enforces + * (config, settings export, collection v2). An out-of-range value is written + * straight into the persisted global settings, so it outlives the collection + * that supplied it: a value below 1 makes CardGrid discard every card on + * flip, and a non-finite one serialises to null and does the same after the + * next reload. Matches the settings-panel stepper's maximum. + */ +const MAX_VISIBLE_CARDS = 10; + +/** + * Upper bound on the number of searchable field paths a collection may set. + * + * Search resolves every field on every card for each settled query, so an + * unbounded list scales that product with attacker-chosen input and freezes + * the tab. Like `maxVisibleCards`, the value persists globally and is not + * covered by the forced-settings restore, so it would follow the visitor to + * every later collection. The built-in default uses three fields. + */ +const MAX_SEARCH_FIELDS = 32; + /** * Load collection settings from a collection directory. * @@ -227,8 +251,11 @@ function validateDefaultSettings( } // maxVisibleCards - if (typeof raw.maxVisibleCards === "number" && raw.maxVisibleCards > 0) { - defaults.maxVisibleCards = Math.floor(raw.maxVisibleCards); + if (typeof raw.maxVisibleCards === "number") { + const requested = Math.floor(raw.maxVisibleCards); + if (Number.isFinite(requested) && requested >= 1) { + defaults.maxVisibleCards = Math.min(requested, MAX_VISIBLE_CARDS); + } } // shuffleOnLoad @@ -243,9 +270,9 @@ function validateDefaultSettings( // searchFields if (Array.isArray(raw.searchFields)) { - defaults.searchFields = raw.searchFields.filter( - (f): f is string => typeof f === "string" - ); + defaults.searchFields = raw.searchFields + .filter((f): f is string => typeof f === "string") + .slice(0, MAX_SEARCH_FIELDS); } return defaults; diff --git a/src/mechanics/quiz/generators/fillTheBlank.ts b/src/mechanics/quiz/generators/fillTheBlank.ts index 4454def..95f7560 100644 --- a/src/mechanics/quiz/generators/fillTheBlank.ts +++ b/src/mechanics/quiz/generators/fillTheBlank.ts @@ -7,7 +7,12 @@ import { shuffle } from "@/utils/shuffle"; import type { Question, Answer } from "../types"; -import type { QuestionGenerator, GeneratorCardData, GeneratorOptions, GeneratorCheckResult } from "./types"; +import type { + QuestionGenerator, + GeneratorCardData, + GeneratorOptions, + GeneratorCheckResult, +} from "./types"; import { generateQuestionId, generateAnswerId, @@ -23,7 +28,10 @@ import { */ const FILL_BLANK_FIELDS = [ { field: "year", promptTemplate: '"{title}" was released in ____.' }, - { field: "categoryShort", promptTemplate: '"{title}" belongs to the ____ category.' }, + { + field: "categoryShort", + promptTemplate: '"{title}" belongs to the ____ category.', + }, { field: "categoryTitle", promptTemplate: '"{title}" is from ____.' }, ] as const; @@ -32,13 +40,21 @@ const FILL_BLANK_FIELDS = [ */ function findBestField( cards: GeneratorCardData[] -): { field: string; promptTemplate: string; uniqueValues: string[]; cards: GeneratorCardData[] } | null { +): { + field: string; + promptTemplate: string; + uniqueValues: string[]; + cards: GeneratorCardData[]; +} | null { for (const config of FILL_BLANK_FIELDS) { const cardsWithField = filterCardsWithField(cards, config.field); const uniqueValues = getUniqueFieldValues(cardsWithField, config.field); // Need at least 4 unique values for multiple choice - if (cardsWithField.length >= MIN_CARDS_FOR_QUIZ && uniqueValues.length >= MIN_CARDS_FOR_QUIZ) { + if ( + cardsWithField.length >= MIN_CARDS_FOR_QUIZ && + uniqueValues.length >= MIN_CARDS_FOR_QUIZ + ) { return { field: config.field, promptTemplate: config.promptTemplate, @@ -62,11 +78,17 @@ function buildTitleToValuesMap( for (const card of cards) { const value = card[field]; - if (value === undefined || value === null || value === "" || typeof value === "object") { + if ( + value === undefined || + value === null || + value === "" || + typeof value === "object" + ) { continue; } - const valueStr = typeof value === "string" ? value : String(value as number | boolean); + const valueStr = + typeof value === "string" ? value : String(value as number | boolean); const existingValues = titleToValues.get(card.title) ?? new Set(); existingValues.add(valueStr); titleToValues.set(card.title, existingValues); @@ -86,7 +108,11 @@ function generateQuestion( titleToValuesMap: Map> ): Question | null { const correctValue = card[field]; - if (correctValue === undefined || correctValue === null || correctValue === "") { + if ( + correctValue === undefined || + correctValue === null || + correctValue === "" + ) { return null; } @@ -95,7 +121,10 @@ function generateQuestion( return null; } - const correctLabel = typeof correctValue === "string" ? correctValue : String(correctValue as number | boolean); + const correctLabel = + typeof correctValue === "string" + ? correctValue + : String(correctValue as number | boolean); // Get all correct values for this title (handles duplicate titles) const allCorrectForTitle = titleToValuesMap.get(card.title); @@ -110,26 +139,36 @@ function generateQuestion( }; // Create alternative correct answers (other valid values for same title) - const alternativeLabels = allCorrectLabels.filter((label) => label !== correctLabel); + // Same shape as relationshipToName: cap the rendered alternatives and + // match against a Set rather than scanning the correct list per value. + const correctLabelSet = new Set(allCorrectLabels); + const alternativeLabels = allCorrectLabels + .filter((label) => label !== correctLabel) + .slice(0, WRONG_ANSWER_COUNT); const alternativeAnswers: Answer[] = alternativeLabels.map((label) => ({ id: generateAnswerId(), label, })); // Select wrong answers from unique values (excluding ALL correct values) - const otherValues = allUniqueValues.filter((v) => !allCorrectLabels.includes(v)); + const otherValues = allUniqueValues.filter((v) => !correctLabelSet.has(v)); // Need enough wrong answers after accounting for alternative correct answers - const wrongAnswersNeeded = Math.max(0, WRONG_ANSWER_COUNT - alternativeAnswers.length); + const wrongAnswersNeeded = Math.max( + 0, + WRONG_ANSWER_COUNT - alternativeAnswers.length + ); if (otherValues.length < wrongAnswersNeeded) { return null; } const shuffledOthers = shuffle(otherValues); - const wrongAnswers: Answer[] = shuffledOthers.slice(0, wrongAnswersNeeded).map((value) => ({ - id: generateAnswerId(), - label: value, - })); + const wrongAnswers: Answer[] = shuffledOthers + .slice(0, wrongAnswersNeeded) + .map((value) => ({ + id: generateAnswerId(), + label: value, + })); // Add alternative correct answers to wrong answers array const allWrongAnswers = [...wrongAnswers, ...alternativeAnswers]; @@ -144,9 +183,10 @@ function generateQuestion( correctAnswer, wrongAnswers: allWrongAnswers, relatedCardId: card.id, - alternativeCorrectIds: alternativeAnswers.length > 0 - ? alternativeAnswers.map((a) => a.id) - : undefined, + alternativeCorrectIds: + alternativeAnswers.length > 0 + ? alternativeAnswers.map((a) => a.id) + : undefined, metadata: { field, }, @@ -165,7 +205,8 @@ export const fillTheBlankGenerator: QuestionGenerator = { if (!fieldConfig) { return { canGenerate: false, - reason: "No suitable field found with enough unique values for Fill the Blank questions.", + reason: + "No suitable field found with enough unique values for Fill the Blank questions.", }; } @@ -184,7 +225,10 @@ export const fillTheBlankGenerator: QuestionGenerator = { } // Build title-to-values map for handling duplicate titles - const titleToValuesMap = buildTitleToValuesMap(fieldConfig.cards, fieldConfig.field); + const titleToValuesMap = buildTitleToValuesMap( + fieldConfig.cards, + fieldConfig.field + ); const questions: Question[] = []; const usedCardIds = new Set(options.excludeCardIds); diff --git a/src/mechanics/quiz/generators/relationshipToName.ts b/src/mechanics/quiz/generators/relationshipToName.ts index 8c8648e..5962525 100644 --- a/src/mechanics/quiz/generators/relationshipToName.ts +++ b/src/mechanics/quiz/generators/relationshipToName.ts @@ -7,7 +7,12 @@ import { shuffle } from "@/utils/shuffle"; import type { Question, Answer } from "../types"; -import type { QuestionGenerator, GeneratorCardData, GeneratorOptions, GeneratorCheckResult } from "./types"; +import type { + QuestionGenerator, + GeneratorCardData, + GeneratorOptions, + GeneratorCheckResult, +} from "./types"; import { generateQuestionId, generateAnswerId, @@ -83,7 +88,9 @@ function getResolvedNames(card: GeneratorCardData, field: string): string[] { /** * Find relationships that can be used for questions. */ -function findUsableRelationships(cards: GeneratorCardData[]): RelationshipInfo[] { +function findUsableRelationships( + cards: GeneratorCardData[] +): RelationshipInfo[] { const relationships: RelationshipInfo[] = []; const fieldCounts = new Map>(); const fieldCards = new Map(); @@ -120,7 +127,10 @@ function findUsableRelationships(cards: GeneratorCardData[]): RelationshipInfo[] const cardsWithField = fieldCards.get(field) ?? []; // Need at least 4 unique values for wrong answers - if (uniqueNames.size >= MIN_CARDS_FOR_QUIZ && cardsWithField.length >= MIN_CARDS_FOR_QUIZ) { + if ( + uniqueNames.size >= MIN_CARDS_FOR_QUIZ && + cardsWithField.length >= MIN_CARDS_FOR_QUIZ + ) { // Generate human-readable label // Convert camelCase or snake_case to Title Case const label = field @@ -169,7 +179,11 @@ function generatePrompt(cardTitle: string, relationshipLabel: string): string { if (lowerLabel.includes("series") || lowerLabel.includes("franchise")) { return `What series is "${cardTitle}" part of?`; } - if (lowerLabel.includes("location") || lowerLabel.includes("region") || lowerLabel.includes("country")) { + if ( + lowerLabel.includes("location") || + lowerLabel.includes("region") || + lowerLabel.includes("country") + ) { return `Where is "${cardTitle}" from?`; } @@ -235,27 +249,41 @@ function generateQuestion( // Create alternative correct answers (other valid values for same title) // These will be included as selectable options and tracked for correct-answer checking - const alternativeNames = allCorrectNames.filter((name) => name !== correctName); + // Both the alternatives and the unique-value pool are sized by the + // collection, so cap the alternatives offered (each is a rendered option) + // and match against a Set — a linear scan per candidate value multiplies + // the two untrusted dimensions and freezes the main thread. + const correctNameSet = new Set(allCorrectNames); + const alternativeNames = allCorrectNames + .filter((name) => name !== correctName) + .slice(0, WRONG_ANSWER_COUNT); const alternativeAnswers: Answer[] = alternativeNames.map((name) => ({ id: generateAnswerId(), label: name, })); // Select wrong answers from other unique values (excluding ALL correct values) - const otherValues = relationship.uniqueValues.filter((v) => !allCorrectNames.includes(v)); + const otherValues = relationship.uniqueValues.filter( + (v) => !correctNameSet.has(v) + ); // Need enough wrong answers after accounting for alternative correct answers // (alternatives take up slots that would otherwise be wrong answers) - const wrongAnswersNeeded = Math.max(0, WRONG_ANSWER_COUNT - alternativeAnswers.length); + const wrongAnswersNeeded = Math.max( + 0, + WRONG_ANSWER_COUNT - alternativeAnswers.length + ); if (otherValues.length < wrongAnswersNeeded) { return null; } const shuffledOthers = shuffle(otherValues); - const wrongAnswers: Answer[] = shuffledOthers.slice(0, wrongAnswersNeeded).map((value) => ({ - id: generateAnswerId(), - label: value, - })); + const wrongAnswers: Answer[] = shuffledOthers + .slice(0, wrongAnswersNeeded) + .map((value) => ({ + id: generateAnswerId(), + label: value, + })); // Add alternative correct answers to wrong answers array // (they display as options but are tracked as correct via alternativeCorrectIds) @@ -272,9 +300,10 @@ function generateQuestion( wrongAnswers: allWrongAnswers, relatedCardId: card.id, // Include alternative correct IDs if there are any - alternativeCorrectIds: alternativeAnswers.length > 0 - ? alternativeAnswers.map((a) => a.id) - : undefined, + alternativeCorrectIds: + alternativeAnswers.length > 0 + ? alternativeAnswers.map((a) => a.id) + : undefined, metadata: { field: relationship.field, }, @@ -322,7 +351,9 @@ export const relationshipToNameGenerator: QuestionGenerator = { const usedTitles = new Set(); // Track titles to avoid duplicate questions // Distribute questions across available relationships - const questionsPerRelationship = Math.ceil(options.count / relationships.length); + const questionsPerRelationship = Math.ceil( + options.count / relationships.length + ); for (const relationship of relationships) { if (questions.length >= options.count) { @@ -330,7 +361,10 @@ export const relationshipToNameGenerator: QuestionGenerator = { } // Build title-to-values map for this relationship - const titleToValuesMap = buildTitleToValuesMap(relationship.cards, relationship.field); + const titleToValuesMap = buildTitleToValuesMap( + relationship.cards, + relationship.field + ); // Filter to unused cards (and unused titles to avoid duplicate questions) let candidateCards = relationship.cards.filter( @@ -345,7 +379,11 @@ export const relationshipToNameGenerator: QuestionGenerator = { // Shuffle candidates candidateCards = shuffle(candidateCards); - for (let i = 0; i < questionsToGenerate && candidateCards.length > 0; i++) { + for ( + let i = 0; + i < questionsToGenerate && candidateCards.length > 0; + i++ + ) { const card = candidateCards[i]; if (!card) continue; diff --git a/src/mechanics/snap-ranking/store.ts b/src/mechanics/snap-ranking/store.ts index bdd344b..2cdc6c4 100644 --- a/src/mechanics/snap-ranking/store.ts +++ b/src/mechanics/snap-ranking/store.ts @@ -141,8 +141,13 @@ export const useSnapRankingStore = create((set, get) => ({ return; } - // Build card values map - const cardValues: Record = {}; + // Build card values map. + // + // Card ids come from untrusted collection data, so a null-prototype + // object is used: assigning a primitive to "__proto__" on a plain object + // literal is a silent no-op, which left the card dealt but unscoreable + // because the read below returned Object.prototype rather than undefined. + const cardValues = Object.create(null) as Record; for (const card of config.cards) { cardValues[card.id] = card.value; } @@ -198,6 +203,7 @@ export const useSnapRankingStore = create((set, get) => ({ const cardId = state.cardIds[state.currentIndex]; if (!cardId) return; + if (!Object.hasOwn(state.cardValues, cardId)) return; const actualValue = state.cardValues[cardId]; if (actualValue === undefined) return; diff --git a/src/services/imageCache.ts b/src/services/imageCache.ts index 4cfedf3..05edc44 100644 --- a/src/services/imageCache.ts +++ b/src/services/imageCache.ts @@ -5,13 +5,37 @@ * Implements LRU eviction when storage limits are reached. */ -import { getDB, type CachedImage, type CacheMetadata } from "@/db"; +import { + getDB, + type CachedImage, + type CacheMetadata, + type ItemdeckDB, +} from "@/db"; +import type { IDBPTransaction } from "idb"; + +/** A readwrite transaction spanning both cache stores. */ +type CacheTransaction = IDBPTransaction< + ItemdeckDB, + ["images", "metadata"], + "readwrite" +>; /** * Default maximum cache size in bytes (50MB). */ const DEFAULT_MAX_CACHE_SIZE = 50 * 1024 * 1024; +/** + * Upper bound on the images preloaded in one pass. + * + * The preload list is the flattened `imageUrls` of every card, and both the + * entity count and the per-card media list come from untrusted collection + * data. The loading overlay only clears at 100% progress and offers no skip, + * so an unbounded list is a lockout rather than a slow load. Generous enough + * that real collections never reach it. + */ +const MAX_PRELOAD_URLS = 2000; + /** * Metadata key for image cache stats. */ @@ -48,6 +72,77 @@ export interface CacheStats { usagePercent: number; } +/** + * Read the cache totals inside an open transaction. + * + * Falls back to a full scan when the metadata record is missing or has + * drifted into an impossible state, so a single bad record cannot make the + * cache stop evicting (totals reading low) or evict everything (reading + * high) forever. The incremental path is what keeps writes off the + * quadratic rescan; this is the bounded safety net for it. + */ +async function readTotals( + tx: CacheTransaction +): Promise<{ imageCount: number; totalSize: number }> { + const stored = await tx.objectStore("metadata").get(IMAGE_CACHE_METADATA_KEY); + + if (stored && stored.imageCount >= 0 && stored.totalSize >= 0) { + return { imageCount: stored.imageCount, totalSize: stored.totalSize }; + } + + let totalSize = 0; + let imageCount = 0; + for await (const cursor of tx.objectStore("images")) { + totalSize += cursor.value.size; + imageCount++; + } + return { imageCount, totalSize }; +} + +/** + * Evict least recently used images inside an open transaction. + * + * Keys are collected during the index walk and deleted afterwards, within + * the same transaction, rather than deleting through the live cursor. + * + * @param tx - Open readwrite transaction over both cache stores + * @param currentSize - Cache size before eviction + * @param targetSize - Size to get at or below + * @param protectUrl - Url that must not be evicted (it is being written) + * @returns Bytes and record count actually freed + */ +async function evictWithin( + tx: CacheTransaction, + currentSize: number, + targetSize: number, + protectUrl?: string +): Promise<{ size: number; count: number }> { + const floor = Math.max(0, targetSize); + let remaining = currentSize; + let size = 0; + const keys: string[] = []; + + const index = tx.objectStore("images").index("by-last-accessed"); + for await (const cursor of index) { + if (remaining <= floor) { + break; + } + if (cursor.value.url === protectUrl) { + continue; + } + keys.push(cursor.value.url); + remaining -= cursor.value.size; + size += cursor.value.size; + } + + const images = tx.objectStore("images"); + for (const key of keys) { + await images.delete(key); + } + + return { size, count: keys.length }; +} + /** * Image cache service. */ @@ -147,17 +242,53 @@ export const imageCache = { return; } - // Check if we need to evict old images - const stats = await this.getStats(); - if (stats.totalSize + blob.size > maxSize) { - await this.evictLRU(blob.size, maxSize); + // Admission, eviction and the write all happen in one transaction. + // Reading the totals beforehand and evicting separately let the five + // concurrent writes the preloader issues each decide from the same + // stale snapshot, so the budget could be overshot or the cache evicted + // far below its target. IndexedDB serialises overlapping readwrite + // transactions on these stores, so each caller now sees the previous + // one's committed state. + const tx = db.transaction(["images", "metadata"], "readwrite"); + const images = tx.objectStore("images"); + const metadataStore = tx.objectStore("metadata"); + + let totals = await readTotals(tx); + const previous = await images.get(url); + + // Re-storing a url replaces the existing record rather than adding + // one, so only its size difference counts towards the budget. + const displaced = previous?.size ?? 0; + const projectedSize = totals.totalSize - displaced + blob.size; + + if (projectedSize > maxSize) { + // Free enough for this image, protecting the record being written so + // eviction cannot delete the entry we are about to replace. + const freed = await evictWithin( + tx, + totals.totalSize - displaced, + maxSize - blob.size, + url + ); + totals = { + imageCount: totals.imageCount - freed.count, + totalSize: totals.totalSize - freed.size, + }; } - // Store the image - await db.put("images", cachedImage); + await images.put(cachedImage); - // Update metadata - await this.updateMetadata(); + await metadataStore.put({ + key: IMAGE_CACHE_METADATA_KEY, + imageCount: Math.max( + 0, + previous ? totals.imageCount : totals.imageCount + 1 + ), + totalSize: Math.max(0, totals.totalSize - displaced + blob.size), + updatedAt: now, + }); + + await tx.done; } catch (error) { console.warn("[imageCache] Failed to cache image:", url, error); } @@ -298,43 +429,29 @@ export const imageCache = { async evictLRU(requiredSpace: number, maxSize: number): Promise { try { const db = await getDB(); - const stats = await this.getStats(maxSize); - - // Calculate how much we need to free. Floor at zero: a requiredSpace - // larger than maxSize would otherwise make the target negative, so the - // break condition below could never be met and the loop would delete - // every cached entry. - const targetSize = Math.max(0, maxSize - requiredSpace); - let currentSize = stats.totalSize; - - if (currentSize <= targetSize) { - return; // Already have enough space - } - - // Get images sorted by last accessed time (oldest first) - const tx = db.transaction("images", "readwrite"); - const index = tx.objectStore("images").index("by-last-accessed"); - const imagesToDelete: string[] = []; - - for await (const cursor of index) { - if (currentSize <= targetSize) { - break; - } - - imagesToDelete.push(cursor.value.url); - currentSize -= cursor.value.size; + const tx = db.transaction(["images", "metadata"], "readwrite"); + const totals = await readTotals(tx); + + const freed = await evictWithin( + tx, + totals.totalSize, + Math.max(0, maxSize - requiredSpace) + ); + + if (freed.count > 0) { + await tx.objectStore("metadata").put({ + key: IMAGE_CACHE_METADATA_KEY, + imageCount: Math.max(0, totals.imageCount - freed.count), + totalSize: Math.max(0, totals.totalSize - freed.size), + updatedAt: Date.now(), + }); } - // Delete the images - for (const url of imagesToDelete) { - await db.delete("images", url); - } + await tx.done; - await this.updateMetadata(); - - if (imagesToDelete.length > 0) { + if (freed.count > 0) { console.info( - `[imageCache] Evicted ${String(imagesToDelete.length)} images to free space` + `[imageCache] Evicted ${String(freed.count)} images to free space` ); } } catch (error) { @@ -415,11 +532,27 @@ export async function preloadImages( let completed = 0; let successful = 0; + // Bound the work. The url list is assembled from every card in an + // untrusted collection with no aggregate cap, and the loading screen only + // clears once progress reaches 100%, so an unbounded list leaves the app + // permanently stuck behind the overlay. + let targets = urls; + if (targets.length > MAX_PRELOAD_URLS) { + console.warn( + `[imageCache] Collection lists ${String(targets.length)} images; preloading the first ${String(MAX_PRELOAD_URLS)}.` + ); + targets = targets.slice(0, MAX_PRELOAD_URLS); + } + + // Read the cached url set once rather than probing the store per url: the + // probe loop ran to completion before the first progress tick, so a long + // list froze the loading screen with no feedback at all. + const alreadyCached = new Set(await imageCache.getAllURLs()); + // Filter out already cached URLs const uncachedUrls: string[] = []; - for (const url of urls) { - const isCached = await imageCache.has(url); - if (!isCached) { + for (const url of targets) { + if (!alreadyCached.has(url)) { uncachedUrls.push(url); } else { completed++; @@ -429,7 +562,7 @@ export async function preloadImages( // Report initial progress (already cached) if (onProgress) { - onProgress(completed, urls.length); + onProgress(completed, targets.length); } // Fetch remaining images in batches @@ -449,7 +582,7 @@ export async function preloadImages( } if (onProgress) { - onProgress(completed, urls.length); + onProgress(completed, targets.length); } } diff --git a/src/types/links.ts b/src/types/links.ts index 9aa8eee..3034401 100644 --- a/src/types/links.ts +++ b/src/types/links.ts @@ -48,8 +48,14 @@ export function isDetailLink(value: unknown): value is DetailLink { * or data: schemes) are dropped, so collection-supplied links are * sanitised at this choke point. * + * The entity schema is loose, so source and label arrive with arbitrary + * shapes. Both are rendered directly — source is also lowercased while + * categorising the sources overlay — so a non-string value throws during + * render and takes the whole card grid down to the query error boundary. + * Drop anything that is not a string here rather than at each sink. + * * @param urls - String URL, single DetailLink, or array - * @returns Array of DetailLink objects with safe URLs + * @returns Array of DetailLink objects with safe URLs and string metadata */ export function normaliseDetailUrls( urls: DetailUrls | undefined | null @@ -65,9 +71,21 @@ export function normaliseDetailUrls( ? urls : [urls]; - return links.filter( - (link) => isDetailLink(link) && safeExternalUrl(link.url) !== null - ); + return links + .filter((link) => isDetailLink(link) && safeExternalUrl(link.url) !== null) + .map((link) => { + const safe: DetailLink = { url: link.url }; + if (typeof link.source === "string") { + safe.source = link.source; + } + if (typeof link.label === "string") { + safe.label = link.label; + } + if (typeof link.isPrimary === "boolean") { + safe.isPrimary = link.isPrimary; + } + return safe; + }); } /** diff --git a/src/utils/cacheConsent.ts b/src/utils/cacheConsent.ts new file mode 100644 index 0000000..f40df5c --- /dev/null +++ b/src/utils/cacheConsent.ts @@ -0,0 +1,52 @@ +/** + * Decide whether a collection's data may be cached locally. + * + * Kept separate from the loading screen so the rule is testable on its own + * and stays consistent between the "do we need to ask" and "may we cache" + * questions, which are not the same: declining permanently answers the first + * while still forbidding the second. + */ + +/** Stored answer to the caching question. */ +export type CacheConsentPreference = "always" | "ask" | "never"; + +/** Inputs to the caching decision for the active source. */ +export interface CacheConsentInput { + /** Whether a source is active at all */ + hasActiveSource: boolean; + + /** Built-in sources ship with the app and are exempt */ + isBuiltIn: boolean; + + /** The visitor's stored preference */ + preference: CacheConsentPreference; + + /** Whether this specific source was granted consent */ + hasSourceConsent: boolean; +} + +/** + * Whether images and collection data may be written to local storage. + * + * "never" must forbid caching, not merely suppress the dialog: suppressing + * the prompt while still caching made "Never cache" weaker than declining + * once, which is the opposite of what the setting promises. + * + * @param input - Active source and preference state + * @returns True when caching is permitted + */ +export function mayCacheCollection(input: CacheConsentInput): boolean { + if (!input.hasActiveSource) { + return false; + } + if (input.isBuiltIn) { + return true; + } + if (input.preference === "never") { + return false; + } + if (input.preference === "always") { + return true; + } + return input.hasSourceConsent; +} diff --git a/src/utils/filterOptions.ts b/src/utils/filterOptions.ts new file mode 100644 index 0000000..fdaee3d --- /dev/null +++ b/src/utils/filterOptions.ts @@ -0,0 +1,41 @@ +/** + * Bounds for the filter option lists offered by the search bar. + * + * Filter values are derived from untrusted collection data, and the filter + * dropdown renders one checkbox per option with no virtualisation. + */ + +/** + * Upper bound on the options offered for one filter field. + * + * The dropdown mounts every option and re-reconciles the whole list whenever + * a filter is toggled, so an unbounded list freezes the tab two clicks after + * load. A per-entity array such as `genres` has no ceiling at all, and the + * card-derived fields are bounded only by the loader's entity cap, which is + * an order of magnitude beyond anything usable as a filter — so every field + * gets the same bound. Real collections use tens of values per field. + */ +export const MAX_FILTER_OPTIONS = 1000; + +/** + * Truncate an over-long filter option list, warning when it bites. + * + * Mirrors the truncate-and-warn shape of the collection loader's entity + * caps, so an honest collection that grows past the bound is visible in the + * console rather than silently trimmed. + * + * @param options - Distinct values collected for one filter field + * @param field - Field name, used in the truncation warning + * @returns The options, truncated to MAX_FILTER_OPTIONS + */ +export function capFilterOptions(options: T[], field: string): T[] { + if (options.length <= MAX_FILTER_OPTIONS) { + return options; + } + + console.warn( + `Collection lists ${String(options.length)} distinct ${field} values; offering the first ${String(MAX_FILTER_OPTIONS)} as filters.` + ); + + return options.slice(0, MAX_FILTER_OPTIONS); +} diff --git a/tests/components/LoadingScreen.cacheConsent.test.tsx b/tests/components/LoadingScreen.cacheConsent.test.tsx new file mode 100644 index 0000000..6dea567 --- /dev/null +++ b/tests/components/LoadingScreen.cacheConsent.test.tsx @@ -0,0 +1,97 @@ +/** + * The loading screen must not preload images when caching was declined. + * + * "Never cache" only suppressed the consent prompt: preloading still fetched + * every image from the third-party host and wrote it to IndexedDB, leaving + * the setting weaker than declining once. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const preload = vi.fn(() => Promise.resolve(0)); + +vi.mock("@/context/CollectionDataContext", () => ({ + useCollectionData: () => ({ + cards: [ + { id: "a", imageUrls: ["https://cdn.example.com/a.png"] }, + { id: "b", imageUrls: ["https://cdn.example.com/b.png"] }, + ], + isLoading: false, + error: null, + }), +})); + +vi.mock("@/hooks/useImageCache", () => ({ + useImagePreloader: () => ({ + preload, + isPreloading: false, + progressPercent: 0, + }), +})); + +vi.mock("@/lib/cardCache", () => ({ + isCollectionCached: () => Promise.resolve(false), + listCachedCollections: () => Promise.resolve([]), +})); + +import { LoadingScreen } from "@/components/LoadingScreen/LoadingScreen"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { useSourceStore } from "@/stores/sourceStore"; + +/** Point the source store at one external (non built-in) source. */ +function useExternalSource() { + useSourceStore.setState({ + activeSourceId: "src-1", + sources: [ + { + id: "src-1", + name: "Remote", + url: "https://cdn.jsdelivr.net/gh/someone/repo@main/data", + isBuiltIn: false, + }, + ], + } as unknown as Parameters[0]); +} + +function renderScreen() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +} + +describe("LoadingScreen cache consent", () => { + beforeEach(() => { + preload.mockClear(); + useExternalSource(); + }); + + it("does not preload images when the visitor chose never", async () => { + useSettingsStore.setState({ cacheConsentPreference: "never" }); + + renderScreen(); + + // Give the effects a chance to run; preload must never be called. + await waitFor(() => { + expect(preload).not.toHaveBeenCalled(); + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(preload).not.toHaveBeenCalled(); + }); + + it("preloads images when the visitor chose always", async () => { + useSettingsStore.setState({ cacheConsentPreference: "always" }); + + renderScreen(); + + await waitFor(() => { + expect(preload).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/loaders/collectionLoader.test.ts b/tests/loaders/collectionLoader.test.ts index 980137b..b5b0a9f 100644 --- a/tests/loaders/collectionLoader.test.ts +++ b/tests/loaders/collectionLoader.test.ts @@ -295,3 +295,54 @@ describe("tolerant entity validation", () => { expect(console.warn).toHaveBeenCalled(); }); }); + +describe("loadEntities duplicate id handling", () => { + // Duplicate ids from an untrusted index defeat CardGrid's random-selection + // guard, which proves "all selected ids still exist" by comparing counts — + // a duplicate makes the count match while an id is missing, throwing during + // render. They also produce duplicate React keys in every view and make one + // flip toggle several cards. + it("keeps one entity per id when an index lists a duplicate", async () => { + const base = "/data/collections/demo"; + stubFetch({ + [`${base}/adverts/index.json`]: ["a", "a", "b"], + [`${base}/adverts/a.json`]: { id: "a", title: "First" }, + [`${base}/adverts/b.json`]: { id: "b", title: "Second" }, + }); + + const entities = await loadEntities(base, "advert"); + + expect(entities.map((e) => e.id)).toEqual(["a", "b"]); + }); + + it("does not refetch an id an index lists twice", async () => { + const base = "/data/collections/demo"; + stubFetch({ + [`${base}/adverts/index.json`]: ["a", "a", "a"], + [`${base}/adverts/a.json`]: { id: "a", title: "First" }, + }); + + await loadEntities(base, "advert"); + + const entityFetches = fetchMock.mock.calls.filter( + (call) => call[0] === `${base}/adverts/a.json` + ); + expect(entityFetches).toHaveLength(1); + }); + + it("keeps one entity per id when a single array file repeats one", async () => { + const base = "/data/collections/demo"; + stubFetch({ + [`${base}/adverts.json`]: [ + { id: "a", title: "First" }, + { id: "a", title: "Impostor" }, + { id: "b", title: "Second" }, + ], + }); + + const entities = await loadEntities(base, "advert"); + + expect(entities.map((e) => e.id)).toEqual(["a", "b"]); + expect(entities[0]?.title).toBe("First"); + }); +}); diff --git a/tests/loaders/relationshipResolver.test.ts b/tests/loaders/relationshipResolver.test.ts index 67243a6..b627295 100644 --- a/tests/loaders/relationshipResolver.test.ts +++ b/tests/loaders/relationshipResolver.test.ts @@ -227,3 +227,61 @@ describe("relationshipResolver", () => { }); }); }); + +describe("relationshipResolver with an attacker-scaled definition", () => { + /** + * Both the relationship count and the entity count come from untrusted + * collection JSON, and neither is capped. Rebuilding the relationship + * entries per entity multiplies the two, so the cost grows with the square + * of the payload rather than with its size. + */ + function hostileDefinition(relationshipCount: number): CollectionDefinition { + const relationships: CollectionDefinition["relationships"] = {}; + for (let i = 0; i < relationshipCount; i++) { + // Keys naming a type that does not exist: every entry is scanned and + // discarded for each entity. + relationships[`ghost${String(i)}.field${String(i)}`] = {}; + } + return { + id: "hostile", + name: "Hostile", + entityTypes: { game: { primary: true, fields: {} } }, + relationships, + }; + } + + it("resolves a large collection without super-linear cost", () => { + const definition = hostileDefinition(10000); + const entities: Entity[] = Array.from({ length: 1000 }, (_, i) => ({ + id: `g${String(i)}`, + title: `Game ${String(i)}`, + })); + const context = createResolverContext(definition, { game: entities }); + + const started = Date.now(); + const resolved = resolveAllRelationships("game", context); + const elapsed = Date.now() - started; + + expect(resolved).toHaveLength(1000); + // Pre-fix this rebuilt all 10000 relationship entries for each of the + // 1000 entities and took several seconds. + expect(elapsed).toBeLessThan(1500); + }); + + it("ranks entities without rescanning the relationship record", () => { + const definition = hostileDefinition(10000); + const entities: Entity[] = Array.from({ length: 1000 }, (_, i) => ({ + id: `g${String(i)}`, + title: `Game ${String(i)}`, + })); + const context = createResolverContext(definition, { game: entities }); + + const started = Date.now(); + for (const entity of entities) { + getEntityRank(entity, "game", context); + } + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(1500); + }); +}); diff --git a/tests/loaders/settingsLoader.test.ts b/tests/loaders/settingsLoader.test.ts index f7db7d8..9e83a1c 100644 --- a/tests/loaders/settingsLoader.test.ts +++ b/tests/loaders/settingsLoader.test.ts @@ -98,3 +98,78 @@ describe("loadCollectionSettings allowlist", () => { }); }); }); + +describe("loadCollectionSettings default bounds", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockSettings(defaults: Record) { + fetchMock.mockResolvedValue({ + ok: true, + headers: { get: () => "application/json" }, + json: async () => ({ version: 1, defaults }), + }); + return loadCollectionSettings("/data/collections/demo"); + } + + it("rejects a fractional maxVisibleCards that would floor to zero", async () => { + // The bound is tested before the floor, so 0.5 would otherwise be stored + // as 0 — CardGrid then discards every card on flip, disabling the app's + // core interaction for every later collection because the value persists. + const result = await mockSettings({ maxVisibleCards: 0.5 }); + + expect(result?.defaults?.maxVisibleCards).toBeUndefined(); + }); + + it("rejects a non-finite maxVisibleCards", async () => { + // JSON.parse("1e400") yields Infinity, which survives `> 0` and later + // serialises to null in localStorage — the same brick after one reload. + const result = await mockSettings({ maxVisibleCards: Infinity }); + + expect(result?.defaults?.maxVisibleCards).toBeUndefined(); + }); + + it("clamps an oversized maxVisibleCards to the settings-panel maximum", async () => { + const result = await mockSettings({ maxVisibleCards: 5000 }); + + expect(result?.defaults?.maxVisibleCards).toBe(10); + }); + + it("keeps an in-range maxVisibleCards", async () => { + const result = await mockSettings({ maxVisibleCards: 3 }); + + expect(result?.defaults?.maxVisibleCards).toBe(3); + }); + + it("caps an oversized searchFields list", async () => { + // Search cost is cards x fields on every settled query, so an unbounded + // field list from an untrusted collection freezes the tab — and the value + // persists globally, following the visitor to every later collection. + const result = await mockSettings({ + searchFields: Array.from({ length: 5000 }, (_, i) => `f${String(i)}`), + }); + + expect(result?.defaults?.searchFields).toHaveLength(32); + expect(result?.defaults?.searchFields?.[0]).toBe("f0"); + }); + + it("keeps a normal searchFields list unchanged", async () => { + const result = await mockSettings({ + searchFields: ["title", "summary", 7, "verdict"], + }); + + expect(result?.defaults?.searchFields).toEqual([ + "title", + "summary", + "verdict", + ]); + }); +}); diff --git a/tests/mechanics/quiz/fillTheBlank.test.ts b/tests/mechanics/quiz/fillTheBlank.test.ts new file mode 100644 index 0000000..49f6b45 --- /dev/null +++ b/tests/mechanics/quiz/fillTheBlank.test.ts @@ -0,0 +1,94 @@ +/** + * Fill-the-blank generation over untrusted card data. + * + * The wrong-answer pool and the set of values sharing a card title are both + * sized by the collection, and the generator scanned the correct set linearly + * for every candidate value. The question also carried one rendered option + * per alternative correct answer. + */ + +import { describe, it, expect } from "vitest"; +import { fillTheBlankGenerator } from "@/mechanics/quiz/generators/fillTheBlank"; +import type { GeneratorCardData } from "@/mechanics/quiz/generators/types"; + +/** Cards sharing one title so their distinct years all count as correct. */ +function cardsSharingTitle(count: number): GeneratorCardData[] { + return Array.from({ length: count }, (_, i) => ({ + id: `c${String(i)}`, + title: "Shared Title", + imageUrl: `https://example.com/${String(i)}.png`, + year: String(1970 + i), + })); +} + +describe("fillTheBlank with untrusted card data", () => { + it("bounds the answer options a single question carries", () => { + const questions = fillTheBlankGenerator.generate(cardsSharingTitle(400), { + count: 1, + }); + + expect(questions.length).toBeGreaterThan(0); + for (const question of questions) { + // Every wrong answer is rendered as its own button: at most the three + // wrong answers plus the three capped alternatives. + expect(question.wrongAnswers.length).toBeLessThanOrEqual(6); + } + }); + + it("generates a full quiz over a large value pool without stalling", () => { + const cards = cardsSharingTitle(8000); + + const started = Date.now(); + fillTheBlankGenerator.generate(cards, { count: 10 }); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(1500); + }); + + it("keeps a normal collection's alternatives selectable", () => { + // Two cards share a title with different years, so the second year is a + // legitimate alternative correct answer and must still be offered. + const cards: GeneratorCardData[] = [ + { + id: "a", + title: "Dual", + imageUrl: "https://example.com/a.png", + year: "1984", + }, + { + id: "b", + title: "Dual", + imageUrl: "https://example.com/b.png", + year: "1986", + }, + { + id: "c", + title: "Other", + imageUrl: "https://example.com/c.png", + year: "1990", + }, + { + id: "d", + title: "Third", + imageUrl: "https://example.com/d.png", + year: "1992", + }, + { + id: "e", + title: "Fourth", + imageUrl: "https://example.com/e.png", + year: "1994", + }, + ]; + + const questions = fillTheBlankGenerator.generate(cards, { count: 5 }); + const dual = questions.find((q) => q.prompt.includes("Dual")); + + expect(dual).toBeDefined(); + expect(dual?.alternativeCorrectIds?.length).toBe(1); + const labels = dual?.wrongAnswers.map((a) => a.label) ?? []; + expect(labels).toContain( + dual?.correctAnswer.label === "1984" ? "1986" : "1984" + ); + }); +}); diff --git a/tests/mechanics/quiz/relationshipToName.test.ts b/tests/mechanics/quiz/relationshipToName.test.ts new file mode 100644 index 0000000..f187bc3 --- /dev/null +++ b/tests/mechanics/quiz/relationshipToName.test.ts @@ -0,0 +1,67 @@ +/** + * Quiz generation over untrusted relationship data. + * + * Relationship values are resolved from collection JSON, so both the pool of + * distinct values and the set of values sharing a card title are sized by the + * collection author. The wrong-answer selection scanned the correct set + * linearly for every candidate value, so the two attacker-scaled dimensions + * multiplied and froze the main thread; the resulting question also carried + * one answer option per alternative. + */ + +import { describe, it, expect } from "vitest"; +import { relationshipToNameGenerator } from "@/mechanics/quiz/generators/relationshipToName"; +import type { GeneratorCardData } from "@/mechanics/quiz/generators/types"; + +/** + * Build cards sharing one title, each carrying a slice of a large studio + * list, so the correct-answer set and the unique-value pool both scale. + */ +function hostileCards(uniqueValues: number, cardCount: number) { + const perCard = Math.ceil(uniqueValues / cardCount); + const cards: GeneratorCardData[] = []; + + for (let i = 0; i < cardCount; i++) { + const studios = Array.from({ length: perCard }, (_, j) => ({ + id: `s${String(i * perCard + j)}`, + title: `Studio ${String(i * perCard + j)}`, + })); + + cards.push({ + id: `c${String(i)}`, + title: "Shared Title", + imageUrl: `https://example.com/${String(i)}.png`, + _resolved: { studio: studios }, + }); + } + + return cards; +} + +describe("relationshipToName with untrusted relationship values", () => { + it("bounds the answer options a single question carries", () => { + const cards = hostileCards(4000, 12); + + const questions = relationshipToNameGenerator.generate(cards, { + count: 1, + }); + + for (const question of questions) { + // At most the three wrong answers plus the three capped alternatives. + expect(question.wrongAnswers.length).toBeLessThanOrEqual(6); + } + }); + + it("generates a full quiz over a large value pool without stalling", () => { + const cards = hostileCards(20000, 12); + + const started = Date.now(); + relationshipToNameGenerator.generate(cards, { count: 10 }); + const elapsed = Date.now() - started; + + // Pre-fix this scanned the correct-answer array once per candidate value + // and took several seconds; the bound is generous to stay stable on slow + // machines while still failing the quadratic scan. + expect(elapsed).toBeLessThan(2000); + }); +}); diff --git a/tests/mechanics/snap-ranking/store.test.ts b/tests/mechanics/snap-ranking/store.test.ts index 16402bf..c783822 100644 --- a/tests/mechanics/snap-ranking/store.test.ts +++ b/tests/mechanics/snap-ranking/store.test.ts @@ -61,3 +61,46 @@ describe("snap-ranking initGame value cap", () => { expect(state.cardIds).toHaveLength(MAX_UNIQUE_VALUES); }); }); + +describe("snap-ranking with prototype-named card ids", () => { + beforeEach(() => { + useSnapRankingStore.setState(useSnapRankingStore.getInitialState()); + }); + + it("scores a card whose id is __proto__", () => { + // Entity ids come from untrusted collection data and are only validated + // as non-empty strings. Assigning a primitive through the inherited + // __proto__ setter is a silent no-op, so the card was dealt but its + // value never stored, and the undefined guard in submitGuess read back + // Object.prototype instead — the card could never be scored. + const config: GameConfig = { + guessField: "order", + cards: [ + { id: "a", value: 1 }, + { id: "__proto__", value: 2 }, + ], + valueType: "numeric", + uniqueValues: [1, 2], + }; + + const store = useSnapRankingStore.getState(); + store.initGame(config); + useSnapRankingStore.setState({ isActive: true }); + + expect(useSnapRankingStore.getState().cardValues["__proto__"]).toBe(2); + + // Play to the __proto__ card and guess its true value. + const cardIds = useSnapRankingStore.getState().cardIds; + const target = cardIds.indexOf("__proto__"); + useSnapRankingStore.setState({ + currentIndex: target, + isCurrentCardFlipped: true, + }); + useSnapRankingStore.getState().submitGuess(2); + + const guesses = useSnapRankingStore.getState().guesses; + expect(guesses).toHaveLength(1); + expect(guesses[0]?.actualValue).toBe(2); + expect(guesses[0]?.score).toBeGreaterThan(0); + }); +}); diff --git a/tests/services/imageCache.test.ts b/tests/services/imageCache.test.ts index aea48e6..b27003f 100644 --- a/tests/services/imageCache.test.ts +++ b/tests/services/imageCache.test.ts @@ -62,7 +62,11 @@ vi.mock("@/db", async () => { }; }); -import { imageCache, DEFAULT_MAX_CACHE_SIZE } from "@/services/imageCache"; +import { + imageCache, + DEFAULT_MAX_CACHE_SIZE, + preloadImages, +} from "@/services/imageCache"; import { deleteDB } from "@/db"; describe("imageCache", () => { @@ -258,3 +262,218 @@ describe("imageCache", () => { }); }); }); + +describe("imageCache metadata maintenance", () => { + beforeEach(async () => { + await imageCache.clear(); + }); + + afterEach(async () => { + await deleteDB(); + }); + + it("keeps metadata accurate across many stores", async () => { + for (let i = 0; i < 50; i++) { + await imageCache.set( + `https://example.com/${String(i)}.jpg`, + new Blob([`payload-${String(i)}`], { type: "image/jpeg" }) + ); + } + + const stats = await imageCache.getStats(); + const urls = await imageCache.getAllURLs(); + + expect(stats.imageCount).toBe(50); + expect(urls).toHaveLength(50); + + let actualSize = 0; + for (const url of urls) { + const image = await imageCache.get(url); + actualSize += image?.size ?? 0; + } + expect(stats.totalSize).toBe(actualSize); + }); + + it("does not double-count when the same url is stored twice", async () => { + const url = "https://example.com/same.jpg"; + + await imageCache.set(url, new Blob(["first"], { type: "image/jpeg" })); + await imageCache.set( + url, + new Blob(["second-and-longer"], { type: "image/jpeg" }) + ); + + const stats = await imageCache.getStats(); + const stored = await imageCache.get(url); + + expect(stats.imageCount).toBe(1); + expect(stats.totalSize).toBe(stored?.size); + }); + + it("keeps metadata accurate after eviction", async () => { + // Budget fits a couple of entries, so storing more forces eviction. + const blob = new Blob([new Uint8Array(1000)], { type: "image/jpeg" }); + const maxSize = blob.size * 3; + + for (let i = 0; i < 8; i++) { + await imageCache.set( + `https://example.com/evict-${String(i)}.jpg`, + new Blob([new Uint8Array(1000)], { type: "image/jpeg" }), + {}, + { maxSize } + ); + } + + const stats = await imageCache.getStats(); + const urls = await imageCache.getAllURLs(); + + expect(stats.imageCount).toBe(urls.length); + expect(stats.totalSize).toBeLessThanOrEqual(maxSize); + }); + + it("stores many images without rescanning the whole store each time", async () => { + const started = Date.now(); + for (let i = 0; i < 800; i++) { + await imageCache.set( + `https://example.com/perf-${String(i)}.jpg`, + new Blob([new Uint8Array(64)], { type: "image/jpeg" }) + ); + } + const elapsed = Date.now() - started; + + // Pre-fix each store walked every existing record to recompute totals, + // so this grew with the square of the image count. + expect(elapsed).toBeLessThan(3000); + }); +}); + +describe("imageCache under concurrent writes", () => { + beforeEach(async () => { + await imageCache.clear(); + }); + + afterEach(async () => { + await deleteDB(); + }); + + /** Store `count` images of `bytes` each, concurrently, as the preloader does. */ + async function storeConcurrently( + prefix: string, + count: number, + bytes: number, + maxSize: number + ) { + await Promise.all( + Array.from({ length: count }, (_, i) => + imageCache.set( + `https://example.com/${prefix}-${String(i)}.jpg`, + new Blob([new Uint8Array(bytes)], { type: "image/jpeg" }), + {}, + { maxSize } + ) + ) + ); + } + + it("never exceeds the budget when writes overlap", async () => { + // The preloader issues writes five at a time. Deciding whether to evict + // from a snapshot read before the write transaction let every concurrent + // caller conclude independently that it still fitted, overshooting the + // budget. + const bytes = 400; + const maxSize = bytes * 10; + + await storeConcurrently("seed", 9, bytes, maxSize); + for (let round = 0; round < 4; round++) { + await storeConcurrently(`burst-${String(round)}`, 5, bytes, maxSize); + + const stats = await imageCache.getStats(maxSize); + expect(stats.totalSize).toBeLessThanOrEqual(maxSize); + } + }); + + it("does not over-evict when writes overlap", async () => { + // Concurrent evictions sharing one stale baseline each kept deleting + // until their own target was met, long after a sibling had brought the + // cache back under budget. + const bytes = 400; + const maxSize = bytes * 10; + + await storeConcurrently("seed", 9, bytes, maxSize); + await storeConcurrently("burst", 5, bytes, maxSize); + + const urls = await imageCache.getAllURLs(); + // Evicting only what is needed leaves the cache near its budget, not + // emptied down to the handful just written. + expect(urls.length).toBeGreaterThanOrEqual(8); + }); + + it("keeps the counters consistent with the store after overlapping writes", async () => { + const bytes = 400; + const maxSize = bytes * 10; + + await storeConcurrently("seed", 9, bytes, maxSize); + await storeConcurrently("burst", 5, bytes, maxSize); + + const stats = await imageCache.getStats(maxSize); + const urls = await imageCache.getAllURLs(); + + let actualSize = 0; + for (const url of urls) { + const image = await imageCache.get(url); + actualSize += image?.size ?? 0; + } + + expect(stats.imageCount).toBe(urls.length); + expect(stats.totalSize).toBe(actualSize); + }); +}); + +describe("preloadImages bound", () => { + beforeEach(async () => { + await imageCache.clear(); + }); + + afterEach(async () => { + await deleteDB(); + vi.unstubAllGlobals(); + }); + + it("preloads no more than the cap from an oversized list", async () => { + // The url list is the flattened imageUrls of every card in an untrusted + // collection, and the loading overlay only clears at 100% progress with + // no skip, so an unbounded list is a lockout rather than a slow load. + vi.spyOn(console, "warn").mockImplementation(() => undefined); + const fetchMock = vi.fn(() => Promise.reject(new Error("blocked"))); + vi.stubGlobal("fetch", fetchMock); + + const urls = Array.from( + { length: 2500 }, + (_, i) => `https://example.com/preload-${String(i)}.jpg` + ); + + const totals: number[] = []; + await preloadImages(urls, {}, (_completed, total) => { + totals.push(total); + }); + + // Progress is reported against the capped list, so the loading screen + // can still reach 100%. + expect(totals.every((t) => t === 2000)).toBe(true); + expect(fetchMock.mock.calls.length).toBe(2000); + }); + + it("leaves a normal list untouched", async () => { + const fetchMock = vi.fn(() => Promise.reject(new Error("blocked"))); + vi.stubGlobal("fetch", fetchMock); + + const urls = Array.from( + { length: 30 }, + (_, i) => `https://example.com/small-${String(i)}.jpg` + ); + + await preloadImages(urls); + + expect(fetchMock.mock.calls.length).toBe(30); + }); +}); diff --git a/tests/types/links.test.ts b/tests/types/links.test.ts index e122398..d270b34 100644 --- a/tests/types/links.test.ts +++ b/tests/types/links.test.ts @@ -78,3 +78,49 @@ describe("normaliseDetailUrls", () => { ]); }); }); + +describe("normaliseDetailUrls untrusted metadata", () => { + // The v2 entity schema is loose, so detailUrls entries reach the render + // with arbitrary shapes. Round 5 hardened the URL; source and label were + // still passed through untyped, and SourcesOverlay lowercases source + // during render while CardExpanded renders both as JSX children — so a + // non-string value crashed the whole card grid up to the query boundary. + it("drops a non-string source", () => { + expect( + normaliseDetailUrls({ + url: "https://example.com", + source: 5, + } as unknown as DetailUrls) + ).toEqual([{ url: "https://example.com" }]); + }); + + it("drops an object source", () => { + expect( + normaliseDetailUrls({ + url: "https://example.com", + source: { en: "Wikipedia" }, + } as unknown as DetailUrls) + ).toEqual([{ url: "https://example.com" }]); + }); + + it("drops a non-string label", () => { + expect( + normaliseDetailUrls({ + url: "https://example.com", + label: { en: "Read more" }, + } as unknown as DetailUrls) + ).toEqual([{ url: "https://example.com" }]); + }); + + it("keeps valid string source and label", () => { + expect( + normaliseDetailUrls({ + url: "https://example.com", + source: "Wikipedia", + label: "Read more", + }) + ).toEqual([ + { url: "https://example.com", source: "Wikipedia", label: "Read more" }, + ]); + }); +}); diff --git a/tests/utils/cacheConsent.test.ts b/tests/utils/cacheConsent.test.ts new file mode 100644 index 0000000..0c5c0ef --- /dev/null +++ b/tests/utils/cacheConsent.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for the caching-permission rule. + * + * "Never cache" previously only suppressed the consent dialog while image + * preloading still fetched and persisted every image, making it weaker than + * declining once. + */ + +import { describe, it, expect } from "vitest"; +import { mayCacheCollection } from "@/utils/cacheConsent"; + +const base = { + hasActiveSource: true, + isBuiltIn: false, + preference: "ask" as const, + hasSourceConsent: false, +}; + +describe("mayCacheCollection", () => { + it("refuses when the visitor chose never", () => { + expect(mayCacheCollection({ ...base, preference: "never" })).toBe(false); + }); + + it("refuses when the visitor chose never even after an earlier grant", () => { + expect( + mayCacheCollection({ + ...base, + preference: "never", + hasSourceConsent: true, + }) + ).toBe(false); + }); + + it("allows when the visitor chose always", () => { + expect(mayCacheCollection({ ...base, preference: "always" })).toBe(true); + }); + + it("allows a built-in source regardless of preference", () => { + expect( + mayCacheCollection({ ...base, isBuiltIn: true, preference: "never" }) + ).toBe(true); + }); + + it("defers to the per-source grant in ask mode", () => { + expect(mayCacheCollection(base)).toBe(false); + expect(mayCacheCollection({ ...base, hasSourceConsent: true })).toBe(true); + }); + + it("refuses when no source is active", () => { + expect(mayCacheCollection({ ...base, hasActiveSource: false })).toBe(false); + }); +}); diff --git a/tests/utils/filterOptions.test.ts b/tests/utils/filterOptions.test.ts new file mode 100644 index 0000000..4886895 --- /dev/null +++ b/tests/utils/filterOptions.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the filter option bound. + * + * The filter dropdown renders one checkbox per option with no virtualisation + * and re-reconciles the whole list on every toggle, so an uncapped option + * list built from untrusted collection data freezes the tab. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { capFilterOptions, MAX_FILTER_OPTIONS } from "@/utils/filterOptions"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("capFilterOptions", () => { + it("leaves a realistic option list untouched", () => { + const options = ["Action", "Puzzle", "Racing"]; + + expect(capFilterOptions(options, "genres")).toBe(options); + }); + + it("leaves a list exactly at the bound untouched", () => { + const options = Array.from({ length: MAX_FILTER_OPTIONS }, (_, i) => + String(i) + ); + + expect(capFilterOptions(options, "genres")).toHaveLength( + MAX_FILTER_OPTIONS + ); + }); + + it("truncates an attacker-scaled option list", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const options = Array.from({ length: 100000 }, (_, i) => `g${String(i)}`); + + const capped = capFilterOptions(options, "genres"); + + expect(capped).toHaveLength(MAX_FILTER_OPTIONS); + expect(capped[0]).toBe("g0"); + expect(warn).toHaveBeenCalledTimes(1); + }); +});