From fa665a1bfafb07e78671ab9993d8eea0b93c309d Mon Sep 17 00:00:00 2001 From: TINEROW <301477771+TINEROW@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:40:39 +0100 Subject: [PATCH] fix(dashboard): validate imported widget config shape --- .../__tests__/useDashboardWidgets.test.tsx | 93 +++++++++++++++++++ src/app/hooks/useDashboardWidgets.tsx | 32 ++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/__tests__/useDashboardWidgets.test.tsx b/src/app/hooks/__tests__/useDashboardWidgets.test.tsx index 4b52e333..a82dc32d 100644 --- a/src/app/hooks/__tests__/useDashboardWidgets.test.tsx +++ b/src/app/hooks/__tests__/useDashboardWidgets.test.tsx @@ -70,6 +70,99 @@ describe('useDashboardWidgets', () => { expect(saved.length).toBe(api.widgets.length); }); + it('imports a valid widget config and persists it', async () => { + let api: any; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + const config = { + widgets: [ + { + id: 'stat-revenue', + type: 'progress-summary', + title: 'Total Revenue', + size: 'small', + position: 0, + isCollapsed: false, + settings: { statType: 'revenue' }, + }, + ], + version: '1.0.0', + }; + + let imported = false; + await act(async () => { + imported = api.importWidgetConfig(config); + }); + expect(imported).toBe(true); + expect(api.widgets).toEqual(config.widgets); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + const saved = JSON.parse(localStorage.getItem('dashboard-widgets') || '[]'); + expect(saved).toEqual(config.widgets); + }); + + it('rejects a widget config with malformed entries', async () => { + let api: any; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + const malformed = { + widgets: [ + { + id: 'broken', + type: 'progress-summary', + title: 'Broken', + // missing size and position + isCollapsed: false, + settings: {}, + }, + ], + }; + + let imported = true; + await act(async () => { + imported = api.importWidgetConfig(malformed); + }); + expect(imported).toBe(false); + expect(api.widgets.some((w: any) => w.id === 'broken')).toBe(false); + }); + + it('rejects a non-array widget config', async () => { + let api: any; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + let imported = true; + await act(async () => { + imported = api.importWidgetConfig({ widgets: { id: 'not-an-array' } }); + }); + expect(imported).toBe(false); + }); + it('adds, reorders, updates, collapses, resizes, and removes widgets', async () => { let api: any; await act(async () => { diff --git a/src/app/hooks/useDashboardWidgets.tsx b/src/app/hooks/useDashboardWidgets.tsx index cef32438..73a2f389 100644 --- a/src/app/hooks/useDashboardWidgets.tsx +++ b/src/app/hooks/useDashboardWidgets.tsx @@ -1,6 +1,7 @@ import { createLogger } from '@/lib/logging'; const logger = createLogger('use-dashboard-widgets'); import { useState, useEffect, useCallback, useRef } from 'react'; +import { z } from 'zod'; interface Widget { id: string; @@ -12,6 +13,18 @@ interface Widget { settings: Record; } +const widgetSchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + title: z.string().min(1), + size: z.enum(['small', 'medium', 'large']), + position: z.number().int().nonnegative(), + isCollapsed: z.boolean(), + settings: z.record(z.string(), z.unknown()), +}); + +const widgetListSchema = z.array(widgetSchema); + // Default widgets matching Figma design — single source of truth const DEFAULT_WIDGETS: Widget[] = [ // 4 Summary Stat Cards (small size) @@ -256,12 +269,21 @@ export const useDashboardWidgets = () => { const importWidgetConfig = useCallback( (config: { widgets?: Widget[] }) => { try { - if (config.widgets && Array.isArray(config.widgets)) { - setWidgets(config.widgets); - saveWidgetLayout(config.widgets); - return true; + if (!Array.isArray(config.widgets)) { + return false; } - return false; + + const result = widgetListSchema.safeParse(config.widgets); + if (!result.success) { + logger.error('Failed to import widget config: malformed widgets', { + errors: result.error.issues, + }); + return false; + } + + setWidgets(result.data); + saveWidgetLayout(result.data); + return true; } catch (error) { logger.error('Failed to import widget config', { error }); return false;