diff --git a/dashboard/src/components/EventCard.unknown.test.tsx b/dashboard/src/components/EventCard.unknown.test.tsx new file mode 100644 index 00000000..9820591e --- /dev/null +++ b/dashboard/src/components/EventCard.unknown.test.tsx @@ -0,0 +1,52 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { EventCard } from './EventCard'; +import type { BlockchainEvent } from '../types/event'; + +const UNKNOWN_TYPE = 'FutureContractEvent'; + +function unknownEvent(overrides: Partial = {}): BlockchainEvent { + return { + eventId: 'evt-unknown-1', + contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12', + eventName: UNKNOWN_TYPE, + ledger: 12345, + type: UNKNOWN_TYPE, + topic: [UNKNOWN_TYPE], + value: '{"weird_field":42}', + receivedAt: Date.now(), + ...overrides, + } as BlockchainEvent; +} + +describe('EventCard unknown event type fallback (issue #612)', () => { + it('renders an unknown event with the neutral fallback badge instead of throwing', () => { + const { container } = render(); + + const card = container.querySelector('.event-card--compact'); + expect(card).toBeTruthy(); + expect(card!.getAttribute('data-event-type-known')).toBe('false'); + expect(container.querySelector('.event-card__badge--default')).toBeTruthy(); + }); + + it('stays inspectable when the payload is not a plain string', () => { + const event = unknownEvent({ + value: { weird_field: 42 } as unknown as string, + topic: undefined as unknown as string[], + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByText(/Unrecognized event type/i)).toBeInTheDocument(); + expect(document.querySelector('.event-card__payload')).toHaveTextContent(/weird_field/); + }); + + it('keeps known events on their mapped presentation', () => { + const { container } = render( + , + ); + + const card = container.querySelector('.event-card--compact'); + expect(card!.getAttribute('data-event-type-known')).toBe('true'); + expect(container.querySelector('.event-card__badge--green')).toBeTruthy(); + }); +}); diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index c828896e..3e2857dd 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -1,12 +1,9 @@ -import { memo, useMemo } from 'react'; import type { BlockchainEvent } from '../types/event'; import type { ContractStatus } from '../services/eventsApi'; -import { formatTimestamp, parseToDate } from '../utils/formatTime'; import { formatTimestamp } from '../utils/formatTime'; +import { getEventKindClass, getEventKindLabel, isKnownEventType } from '../utils/eventTypeMapping'; import { CopyButton } from './CopyButton'; -import { getEventKindClass, getEventKindLabel } from '../utils/eventTypeMapping'; - function shortenAddress(address: string) { if (address.length <= 14) { return address; @@ -20,7 +17,6 @@ interface EventExplorerCardProps { onCopyContract: (contractAddress: string) => void; isCopied: boolean; onSelect?: (event: BlockchainEvent) => void; - contractStatuses: ContractStatus[]; contractStatuses?: ContractStatus[]; } @@ -36,7 +32,7 @@ export function EventExplorerCard({ const label = event.eventName ?? event.type; const badgeClass = getEventKindClass(event.type); const kindLabel = getEventKindLabel(event.type); - const receivedAt = parseToDate(event.receivedAt); + const isUnknown = !isKnownEventType(event.type); return (
onSelect(event) : undefined} onKeyDown={ onSelect @@ -60,16 +57,24 @@ export function EventExplorerCard({

- {shortenedContract} + {shortenAddress(event.contractAddress)}

- +
+ + {isPaused && ( + Paused + )} +
@@ -78,14 +83,17 @@ export function EventExplorerCard({
- {kindLabel} + + {kindLabel} +
-
@@ -108,4 +116,4 @@ export function EventExplorerCard({
); -}); +} diff --git a/dashboard/src/utils/eventTypeMapping.test.ts b/dashboard/src/utils/eventTypeMapping.test.ts new file mode 100644 index 00000000..f3dc9c22 --- /dev/null +++ b/dashboard/src/utils/eventTypeMapping.test.ts @@ -0,0 +1,72 @@ +import { + EVENT_TYPE_PRESENTATIONS, + UNKNOWN_EVENT_TYPE_PRESENTATION, + getEventBadgeClass, + getEventKindClass, + getEventKindLabel, + getEventTypePresentation, + isKnownEventType, +} from './eventTypeMapping'; + +describe('isKnownEventType (issue #612)', () => { + it('returns true for catalogued event names', () => { + expect(isKnownEventType('TaskCreated')).toBe(true); + expect(isKnownEventType('NotificationScheduled')).toBe(true); + }); + + it('matches catalogued event names case-insensitively', () => { + expect(isKnownEventType('taskcreated')).toBe(true); + expect(isKnownEventType('CONTRACT')).toBe(true); + }); + + it('returns false for event types the dashboard has not seen yet', () => { + expect(isKnownEventType('FutureContractEvent')).toBe(false); + expect(isKnownEventType('reputation_updated')).toBe(false); + }); + + it('returns false for empty, null and undefined input', () => { + expect(isKnownEventType('')).toBe(false); + expect(isKnownEventType(null)).toBe(false); + expect(isKnownEventType(undefined)).toBe(false); + }); +}); + +describe('getEventTypePresentation fallback for unknown event types (issue #612)', () => { + it('returns the neutral fallback presentation for an unmapped type', () => { + const presentation = getEventTypePresentation('FutureContractEvent'); + + expect(presentation.badgeClass).toBe(UNKNOWN_EVENT_TYPE_PRESENTATION.badgeClass); + expect(presentation.kindBadgeClass).toBe(UNKNOWN_EVENT_TYPE_PRESENTATION.kindBadgeClass); + expect(presentation.kindLabel).toBe('Unknown'); + expect(presentation.category).toBe('General'); + expect(presentation.color).toBe('gray'); + }); + + it('keeps the raw event type as the label so the payload stays identifiable', () => { + expect(getEventTypePresentation('FutureContractEvent').label).toBe('FutureContractEvent'); + }); + + it('never returns undefined presentation fields for an unknown type', () => { + const presentation = getEventTypePresentation('brand_new_event'); + + for (const value of Object.values(presentation)) { + expect(value).toBeDefined(); + } + }); + + it('still resolves catalogued types exactly', () => { + const known = getEventTypePresentation('TaskCreated'); + + expect(known).toBe(EVENT_TYPE_PRESENTATIONS.TaskCreated); + }); + + it('helpers fall back safely for unknown types', () => { + expect(getEventBadgeClass('FutureContractEvent')).toBe( + UNKNOWN_EVENT_TYPE_PRESENTATION.badgeClass, + ); + expect(getEventKindClass('FutureContractEvent')).toBe( + UNKNOWN_EVENT_TYPE_PRESENTATION.kindBadgeClass, + ); + expect(getEventKindLabel('FutureContractEvent')).toBe('Unknown'); + }); +}); diff --git a/dashboard/src/utils/eventTypeMapping.ts b/dashboard/src/utils/eventTypeMapping.ts index c835cca9..1c3da108 100644 --- a/dashboard/src/utils/eventTypeMapping.ts +++ b/dashboard/src/utils/eventTypeMapping.ts @@ -267,14 +267,10 @@ export const EVENT_TYPE_PRESENTATIONS: Record = { }; /** - * Returns centralized presentation metadata for any given event type or event name. - * Uses a safe fallback if the event type is unsupported or missing. + * Looks up a catalogued presentation, matching the key case-insensitively. + * Returns `undefined` when the event type is not part of the known catalog. */ -export function getEventTypePresentation(eventType?: string | null): EventTypePresentation { - if (!eventType) { - return UNKNOWN_EVENT_TYPE_PRESENTATION; - } - +function findKnownPresentation(eventType: string): EventTypePresentation | undefined { // Exact match first if (EVENT_TYPE_PRESENTATIONS[eventType]) { return EVENT_TYPE_PRESENTATIONS[eventType]; @@ -288,7 +284,40 @@ export function getEventTypePresentation(eventType?: string | null): EventTypePr } } - // Safe fallback + return undefined; +} + +/** + * Returns true only when the event type has a dedicated presentation mapping. + * + * Unknown event types (new contracts, or a listener that is ahead of the + * dashboard build) resolve to `false` so callers can render an explicit + * fallback that keeps the raw payload inspectable (issue #612). + */ +export function isKnownEventType(eventType?: string | null): boolean { + if (!eventType) { + return false; + } + + return findKnownPresentation(eventType) !== undefined; +} + +/** + * Returns centralized presentation metadata for any given event type or event name. + * Uses a safe fallback if the event type is unsupported or missing. + */ +export function getEventTypePresentation(eventType?: string | null): EventTypePresentation { + if (!eventType) { + return UNKNOWN_EVENT_TYPE_PRESENTATION; + } + + const known = findKnownPresentation(eventType); + if (known) { + return known; + } + + // Safe fallback: keep the raw event type as the label so an unknown event + // remains identifiable while the badge falls back to neutral styling. return { ...UNKNOWN_EVENT_TYPE_PRESENTATION, label: eventType, diff --git a/dashboard/src/utils/payloadViewer.test.ts b/dashboard/src/utils/payloadViewer.test.ts new file mode 100644 index 00000000..ccf3c4d4 --- /dev/null +++ b/dashboard/src/utils/payloadViewer.test.ts @@ -0,0 +1,110 @@ +import { formatPayloadPreview, formatRawPayload, sanitizePayload } from './payloadViewer'; + +describe('formatRawPayload with unknown payload shapes (issue #612)', () => { + it('pretty-prints object payloads the dashboard has never seen', () => { + const result = formatRawPayload({ weird_field: 42, nested: { ok: true } }); + + expect(result.isValidJson).toBe(true); + expect(result.formatted).toContain('"weird_field": 42'); + expect(() => JSON.parse(result.formatted)).not.toThrow(); + }); + + it('pretty-prints JSON encoded as a string', () => { + const result = formatRawPayload('{"weird_field":42}'); + + expect(result.isValidJson).toBe(true); + expect(JSON.parse(result.formatted)).toEqual({ weird_field: 42 }); + }); + + it('returns plain text for non-JSON payloads without throwing', () => { + const result = formatRawPayload('not-json-at-all'); + + expect(result.isValidJson).toBe(false); + expect(result.formatted).toBe('not-json-at-all'); + }); + + it('does not throw on circular payloads and stays stringifiable', () => { + const circular: Record = { name: 'loop' }; + circular.self = circular; + + let result: ReturnType | undefined; + expect(() => { + result = formatRawPayload(circular); + }).not.toThrow(); + + expect(result!.formatted).toContain('loop'); + expect(result!.formatted).toContain('[Circular]'); + }); + + it('does not throw on values JSON cannot serialise', () => { + expect(() => formatRawPayload({ big: BigInt(1) })).not.toThrow(); + }); + + it('handles null and undefined payloads', () => { + expect(formatRawPayload(null).formatted).toBe('null'); + expect(formatRawPayload(undefined).formatted).toBe('null'); + }); +}); + +describe('sanitizePayload circular guard', () => { + it('replaces circular references instead of recursing forever', () => { + const circular: Record = {}; + circular.self = circular; + + const sanitized = sanitizePayload(circular) as Record; + + expect(sanitized.self).toBe('[Circular]'); + }); + + it('keeps repeated non-circular references in sibling fields', () => { + const shared = { id: 1 }; + + const sanitized = sanitizePayload({ a: shared, b: shared }) as Record; + + expect(sanitized.a).toEqual({ id: 1 }); + expect(sanitized.b).toEqual({ id: 1 }); + }); +}); + +describe('formatPayloadPreview (issue #612)', () => { + it('renders object payloads as a single inspectable line', () => { + const { preview, full, truncated } = formatPayloadPreview({ weird_field: 42 }); + + expect(preview).toBe('{ "weird_field": 42 }'); + expect(preview).not.toContain('\n'); + expect(full).toContain('"weird_field": 42'); + expect(truncated).toBe(false); + }); + + it('truncates long payloads for the inline preview but keeps the full payload', () => { + const longValue = 'x'.repeat(500); + const { preview, full, truncated } = formatPayloadPreview(longValue, 20); + + expect(truncated).toBe(true); + expect(preview.length).toBe(20); + expect(preview.endsWith('…')).toBe(true); + expect(full).toBe(longValue); + }); + + it('never throws and always returns a string for arbitrary values', () => { + const values: unknown[] = [ + undefined, + null, + 0, + '', + [], + {}, + ['a', { b: 1 }], + 'plain text', + ]; + + for (const value of values) { + let result: ReturnType | undefined; + expect(() => { + result = formatPayloadPreview(value); + }).not.toThrow(); + expect(typeof result!.preview).toBe('string'); + expect(result!.preview.length).toBeGreaterThan(0); + } + }); +}); diff --git a/dashboard/src/utils/payloadViewer.ts b/dashboard/src/utils/payloadViewer.ts index 7800cc21..73a902aa 100644 --- a/dashboard/src/utils/payloadViewer.ts +++ b/dashboard/src/utils/payloadViewer.ts @@ -25,8 +25,12 @@ const SENSITIVE_KEY_PATTERNS = [ /** * Recursively redacts sensitive configuration values from an object or array payload. + * + * Circular references are replaced with the string `"[Circular]"` so that + * arbitrary/unknown event payloads remain stringifiable instead of throwing or + * recursing forever (issue #612). */ -export function sanitizePayload(data: unknown): unknown { +export function sanitizePayload(data: unknown, seen: WeakSet = new WeakSet()): unknown { if (data === null || data === undefined) { return data; } @@ -35,25 +39,36 @@ export function sanitizePayload(data: unknown): unknown { return data; } - if (Array.isArray(data)) { - return data.map(sanitizePayload); + if (seen.has(data as object)) { + return '[Circular]'; } + seen.add(data as object); - const sanitized: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); - if (isSensitive && typeof value === 'string') { - sanitized[key] = '[REDACTED]'; - } else if (isSensitive && typeof value === 'number') { - sanitized[key] = 0; - } else if (isSensitive) { - sanitized[key] = '[REDACTED]'; - } else { - sanitized[key] = sanitizePayload(value); + try { + if (Array.isArray(data)) { + return data.map((item) => sanitizePayload(item, seen)); + } + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); + if (isSensitive && typeof value === 'string') { + sanitized[key] = '[REDACTED]'; + } else if (isSensitive && typeof value === 'number') { + sanitized[key] = 0; + } else if (isSensitive) { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = sanitizePayload(value, seen); + } } - } - return sanitized; + return sanitized; + } finally { + // Track the current path only, so repeated (non-circular) references in + // sibling fields are still fully rendered. + seen.delete(data as object); + } } export interface FormattedPayloadResult { @@ -100,13 +115,22 @@ export function formatRawPayload(value: string | unknown): FormattedPayloadResul } if (isValidJson && parsed !== null) { - const sanitized = sanitizePayload(parsed); - const originalStr = JSON.stringify(parsed); - const sanitizedStr = JSON.stringify(sanitized); - const hasRedactions = originalStr !== sanitizedStr; - try { + const sanitized = sanitizePayload(parsed); const formatted = JSON.stringify(sanitized, null, 2); + if (typeof formatted !== 'string') { + return { formatted: String(value), isValidJson: false, hasRedactions: false }; + } + + let hasRedactions = false; + try { + hasRedactions = JSON.stringify(parsed) !== JSON.stringify(sanitized); + } catch { + // Non-serialisable original (e.g. circular reference or BigInt): + // fall back to detecting the redaction marker in the formatted output. + hasRedactions = formatted.includes('[REDACTED]'); + } + return { formatted, isValidJson: true, hasRedactions }; } catch { return { formatted: String(value), isValidJson: false, hasRedactions: false }; @@ -117,6 +141,40 @@ export function formatRawPayload(value: string | unknown): FormattedPayloadResul return { formatted: String(value), isValidJson: false, hasRedactions: false }; } +export interface PayloadPreviewResult { + /** Single-line, inspectable representation that is always safe to render. */ + preview: string; + /** The full formatted payload (pretty-printed JSON where possible). */ + full: string; + /** True when `preview` was shortened because the payload was long. */ + truncated: boolean; +} + +/** + * Produces a compact, single-line preview of an arbitrary payload for inline + * rendering, together with the full inspectable representation. + * + * Unknown event types can carry payload shapes the dashboard has never seen + * (objects, arrays, primitives), so this helper never throws and always + * returns a string — preventing "Objects are not valid as a React child" + * rendering exceptions (issue #612). + */ +export function formatPayloadPreview(value: unknown, maxLength = 160): PayloadPreviewResult { + const { formatted } = formatRawPayload(value); + const singleLine = formatted.replace(/\s+/g, ' ').trim(); + const safe = singleLine.length > 0 ? singleLine : 'No payload'; + + if (maxLength <= 0 || safe.length <= maxLength) { + return { preview: safe, full: formatted, truncated: false }; + } + + return { + preview: `${safe.slice(0, Math.max(0, maxLength - 1))}…`, + full: formatted, + truncated: true, + }; +} + export interface CopyPayloadResult { success: boolean; copiedText: string;