diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 87c1121..359240f 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -6,15 +6,22 @@ import { createEventStreamClient, type EventStreamClient } from './events/event- import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js'; +import { AutomationModule, type AutomationDataSource } from './instances/automation-module.js'; import { CallsModule, type CallsDataSource } from './instances/calls-module.js'; import { CellularModule, type CellularDataSource } from './instances/cellular-module.js'; import { DeviceNetworkModule, type DeviceNetworkDataSource, } from './instances/device-network-module.js'; +import { EsimModule, type EsimDataSource } from './instances/esim-module.js'; import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js'; import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; +import { + NotificationsModule, + type NotificationsDataSource, +} from './instances/notifications-module.js'; +import { OtaModule, type OtaDataSource } from './instances/ota-module.js'; import { InstanceDetail, INSTANCE_MODULE_LABELS, @@ -74,6 +81,10 @@ export interface AppShellProps { deviceNetworkDataSource?: DeviceNetworkDataSource; messagesDataSource?: MessagesDataSource; callsDataSource?: CallsDataSource; + esimDataSource?: EsimDataSource; + notificationsDataSource?: NotificationsDataSource; + automationDataSource?: AutomationDataSource; + otaDataSource?: OtaDataSource; eventStreamClient?: EventStreamClient; } @@ -146,6 +157,10 @@ function Page({ deviceNetworkDataSource, messagesDataSource, callsDataSource, + esimDataSource, + notificationsDataSource, + automationDataSource, + otaDataSource, fleetRefreshSignal, detailRefreshSignal, }: { @@ -161,6 +176,10 @@ function Page({ deviceNetworkDataSource: DeviceNetworkDataSource | undefined; messagesDataSource: MessagesDataSource | undefined; callsDataSource: CallsDataSource | undefined; + esimDataSource: EsimDataSource | undefined; + notificationsDataSource: NotificationsDataSource | undefined; + automationDataSource: AutomationDataSource | undefined; + otaDataSource: OtaDataSource | undefined; fleetRefreshSignal: number; detailRefreshSignal: number; }): ReactNode { @@ -259,6 +278,50 @@ function Page({ ), } : {})} + {...(module === 'esim' && instance + ? { + moduleContent: ( + + ), + } + : {})} + {...(module === 'notifications' && instance + ? { + moduleContent: ( + + ), + } + : {})} + {...(module === 'automation' && instance + ? { + moduleContent: ( + + ), + } + : {})} + {...(module === 'ota' && instance + ? { + moduleContent: ( + + ), + } + : {})} /> ); } @@ -294,6 +357,10 @@ export function AppShell({ deviceNetworkDataSource, messagesDataSource, callsDataSource, + esimDataSource, + notificationsDataSource, + automationDataSource, + otaDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); @@ -351,6 +418,10 @@ export function AppShell({ deviceNetworkDataSource={deviceNetworkDataSource} messagesDataSource={messagesDataSource} callsDataSource={callsDataSource} + esimDataSource={esimDataSource} + notificationsDataSource={notificationsDataSource} + automationDataSource={automationDataSource} + otaDataSource={otaDataSource} fleetRefreshSignal={refresh.fleet} detailRefreshSignal={refresh.detail} /> diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 8fc0307..ec379c6 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -104,4 +104,31 @@ export { type MessagesSnapshot, } from './instances/messages-module.js'; +export { + AutomationModule, + sanitizeAutomationSnapshot, + type AutomationDataSource, + type AutomationModuleProps, + type AutomationSnapshot, +} from './instances/automation-module.js'; +export { + EsimModule, + type EsimDataSource, + type EsimModuleProps, + type EsimSnapshot, +} from './instances/esim-module.js'; +export { + NotificationsModule, + sanitizeNotificationsSnapshot, + type NotificationsDataSource, + type NotificationsModuleProps, + type NotificationsSnapshot, +} from './instances/notifications-module.js'; +export { + OtaModule, + type OtaDataSource, + type OtaModuleProps, + type OtaSnapshot, +} from './instances/ota-module.js'; + export const webWorkspaceReady = true; diff --git a/apps/web/src/instances/automation-module.test.tsx b/apps/web/src/instances/automation-module.test.tsx new file mode 100644 index 0000000..6b6691f --- /dev/null +++ b/apps/web/src/instances/automation-module.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InstanceContext } from '../app-shell.js'; +import { + AutomationModule, + type AutomationDataSource, + type AutomationSnapshot, +} from './automation-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: AutomationSnapshot = { + observedAt: '2026-07-17T13:00:00Z', + status: { state: 'healthy', scheduler: 'active', workers: 'available' }, + tasks: { total: 12, enabled: 9, disabled: 3, running: 2, succeeded: 7, failed: 1 }, +}; + +function deferredSource() { + let resolve!: (value: unknown) => void; + const load = vi.fn( + (_instanceId, _signal) => + new Promise((done) => { + void _instanceId; + void _signal; + resolve = done; + }), + ); + return { source: { load }, load, resolve: (value: unknown) => resolve(value) }; +} + +describe('isolated Automation read module', () => { + it('loads through the injected exact-owner source and renders only aggregate task status', async () => { + const pending = deferredSource(); + render(); + expect(screen.getByRole('status', { name: 'Automation loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + const counts = await screen.findByRole('region', { name: 'Task counts' }); + expect(within(counts).getByText('12')).toBeTruthy(); + expect(within(counts).getByText('9')).toBeTruthy(); + expect(screen.getByRole('region', { name: 'Automation status' }).textContent).toContain( + 'healthy', + ); + expect(screen.queryByRole('region', { name: 'Safe labels' })).toBeNull(); + expect(screen.getByText(/Observed 2026-07-17T13:00:00Z/)).toBeTruthy(); + }); + + it('omits labels and never exposes task config, endpoints, tokens, phones, messages, payloads, logs, or operations', async () => { + const unsafe = { + ...snapshot, + endpoint: 'https://secret.example/run', + token: 'secret-token', + phoneNumber: '+1-555-0100', + message: 'private message content', + payload: 'private payload', + rawLogs: 'private raw log', + tasks: { ...snapshot.tasks, config: 'secret cron config', endpoint: '/execute' }, + status: { ...snapshot.status, token: 'nested-token', lastMessage: 'nested-message' }, + labels: ['Daily jobs'], + }; + render( unsafe }} />); + expect(await screen.findByText(/read-only aggregate view/i)).toBeTruthy(); + expect(document.body.textContent).not.toMatch( + /Daily jobs|secret\.example|secret-token|555|private message|private payload|private raw log|cron config|execute|nested-token|nested-message/i, + ); + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByText(/read-only aggregate view/i)).toBeTruthy(); + }); + + it('copies and bounds enums, counts, and timestamps while dropping all labels', async () => { + const unsafe = { + observedAt: 'scheduler-token@example.test', + status: { state: 'state-secret', scheduler: 'scheduler-secret', workers: ['worker-secret'] }, + tasks: { total: -1, enabled: 1.5, disabled: 2, running: Number.POSITIVE_INFINITY, failed: 3 }, + labels: [ + 'Safe 1', + 'Safe 2', + 'Safe 3', + 'Safe 4', + 'Safe 5', + 'Safe 6', + 'Safe 7', + 'Safe 8', + 'Safe 9', + 'token@example.test', + '', + 'x'.repeat(33), + 42, + ], + }; + render( unsafe }} />); + await screen.findByRole('region', { name: 'Task counts' }); + expect(within(screen.getByRole('region', { name: 'Task counts' })).getByText('2')).toBeTruthy(); + expect(within(screen.getByRole('region', { name: 'Task counts' })).getByText('3')).toBeTruthy(); + expect(document.body.textContent).not.toMatch( + /scheduler-token|state-secret|scheduler-secret|worker-secret|Safe [1-9]|script|Infinity|1\.5|-1/, + ); + expect(screen.queryByText(/^Observed /)).toBeNull(); + unsafe.labels[0] = 'Mutated'; + unsafe.tasks.disabled = 99; + expect(screen.queryByText('Mutated')).toBeNull(); + expect(screen.queryByText('99')).toBeNull(); + }); + + it('fails closed without authentication or an injected source', () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(load).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole('status', { name: 'Automation unavailable' }).textContent).toMatch( + /no safe Automation read data source.*will not invent.*production endpoint/i, + ); + }); + + it('uses fixed errors, retries, and retains only the same-owner snapshot on refresh failure', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('secret endpoint token')) + .mockResolvedValueOnce({ ...snapshot, tasks: { ...snapshot.tasks, total: 13 } }); + const source = { load }; + const { rerender } = render( + , + ); + const counts = await screen.findByRole('region', { name: 'Task counts' }); + expect(within(counts).getByText('12')).toBeTruthy(); + rerender(); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/showing the last known Automation data/i); + expect(alert.textContent).not.toMatch(/secret endpoint token/i); + expect( + within(screen.getByRole('region', { name: 'Task counts' })).getByText('12'), + ).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Retry loading Automation' })); + expect( + await within(screen.getByRole('region', { name: 'Task counts' })).findByText('13'), + ).toBeTruthy(); + }); + + it('aborts superseded reads and fences late data from another owner', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((id, signal) => + id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal), + ); + const source = { load }; + const { rerender } = render(); + const alphaSignal = load.mock.calls[0]?.[1]; + rerender( + , + ); + expect(alphaSignal?.aborted).toBe(true); + alpha.resolve({ ...snapshot, tasks: { total: 41 } }); + bravo.resolve({ ...snapshot, tasks: { total: 42 } }); + expect(await screen.findByText('42')).toBeTruthy(); + expect(screen.queryByText('41')).toBeNull(); + }); + + it('turns a synchronous source throw into the fixed initial-load error', async () => { + render( + { + throw new Error('private automation token'); + }, + }} + />, + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Automation data could not be loaded.'); + expect(alert.textContent).not.toContain('private automation token'); + }); +}); diff --git a/apps/web/src/instances/automation-module.tsx b/apps/web/src/instances/automation-module.tsx new file mode 100644 index 0000000..f182697 --- /dev/null +++ b/apps/web/src/instances/automation-module.tsx @@ -0,0 +1,296 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Aggregate task counts only; per-task records and configuration are intentionally unsupported. */ +export interface AutomationTaskCounts { + readonly total?: number | null; + readonly enabled?: number | null; + readonly disabled?: number | null; + readonly running?: number | null; + readonly queued?: number | null; + readonly succeeded?: number | null; + readonly failed?: number | null; +} + +/** Bounded high-level status labels only, never status payloads or raw logs. */ +export type AutomationState = 'healthy' | 'degraded' | 'failed' | 'disabled' | 'unknown'; +export type AutomationScheduler = 'active' | 'idle' | 'paused' | 'disabled' | 'unavailable'; +export type AutomationWorkers = 'available' | 'busy' | 'degraded' | 'disabled' | 'unavailable'; +export interface AutomationStatus { + readonly state?: AutomationState | null; + readonly scheduler?: AutomationScheduler | null; + readonly workers?: AutomationWorkers | null; +} + +export interface AutomationSnapshot { + readonly observedAt?: string; + readonly status: AutomationStatus; + readonly tasks: AutomationTaskCounts; +} + +export interface AutomationDataSource { + /** Injected by the authenticated owner; this isolated module defines no production endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface AutomationModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: AutomationDataSource; + readonly refreshSignal?: unknown; +} + +type ReadState = + | { kind: 'idle'; ownerId: string } + | { kind: 'loading'; ownerId: string; snapshot?: AutomationSnapshot } + | { kind: 'ready'; ownerId: string; snapshot: AutomationSnapshot } + | { kind: 'error'; ownerId: string; snapshot?: AutomationSnapshot }; + +const SAFE_LOAD_ERROR = 'Automation data could not be loaded.'; +const STATES = new Set(['healthy', 'degraded', 'failed', 'disabled', 'unknown']); +const SCHEDULERS = new Set([ + 'active', + 'idle', + 'paused', + 'disabled', + 'unavailable', +]); +const WORKERS = new Set([ + 'available', + 'busy', + 'degraded', + 'disabled', + 'unavailable', +]); +const TASK_COUNT_KEYS = [ + 'total', + 'enabled', + 'disabled', + 'running', + 'queued', + 'succeeded', + 'failed', +] as const; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function safeTimestamp(value: unknown): string | undefined { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + ) + return undefined; + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) return undefined; + const canonical = parsed.toISOString(); + return canonical === value || canonical === value.replace(/Z$/, '.000Z') ? value : undefined; +} + +function boundedEnum( + value: unknown, + allowed: ReadonlySet, +): T | null | undefined { + if (value === null) return null; + return typeof value === 'string' && allowed.has(value as T) ? (value as T) : undefined; +} + +/** Copy and validate untrusted source data before it can enter React state. */ +export function sanitizeAutomationSnapshot(value: unknown): AutomationSnapshot { + const source = isRecord(value) ? value : {}; + const rawStatus = isRecord(source.status) ? source.status : {}; + const state = boundedEnum(rawStatus.state, STATES); + const scheduler = boundedEnum(rawStatus.scheduler, SCHEDULERS); + const workers = boundedEnum(rawStatus.workers, WORKERS); + const status: AutomationStatus = { + ...(state !== undefined ? { state } : {}), + ...(scheduler !== undefined ? { scheduler } : {}), + ...(workers !== undefined ? { workers } : {}), + }; + const rawTasks = isRecord(source.tasks) ? source.tasks : {}; + const tasks: Record = {}; + for (const key of TASK_COUNT_KEYS) { + const count = rawTasks[key]; + if (count === null || (Number.isSafeInteger(count) && (count as number) >= 0)) + tasks[key] = count as number | null; + } + const observedAt = safeTimestamp(source.observedAt); + return { + ...(observedAt ? { observedAt } : {}), + status, + tasks, + }; +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +function Fields({ + values, +}: { + values: readonly (readonly [string, string | number | null | undefined])[]; +}) { + const supplied = values.filter(([, value]) => value !== undefined); + if (!supplied.length) return

No aggregate status was supplied.

; + return ( +
+ {supplied.map(([label, value]) => ( +
+
{label}
+
{value == null ? 'Unavailable' : String(value)}
+
+ ))} +
+ ); +} + +function SnapshotView({ snapshot }: { snapshot: AutomationSnapshot }) { + const status = snapshot.status; + const tasks = snapshot.tasks; + return ( + <> + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +

Read-only aggregate view; task details and operational controls are excluded.

+
+
+ +
+
+ +
+
+ + ); +} + +export function AutomationModule({ instance, dataSource, refreshSignal }: AutomationModuleProps) { + const requestFence = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle', ownerId: instance.id }); + + useEffect(() => { + const request = ++requestFence.current; + const ownerId = instance.id; + const controller = new AbortController(); + + if (instance.authentication !== 'authenticated' || !dataSource) { + setState({ kind: 'idle', ownerId }); + return () => controller.abort(); + } + + setState((current) => ({ + kind: 'loading', + ownerId, + ...(current.ownerId === ownerId && current.kind !== 'idle' && current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + + let read: Promise; + try { + read = dataSource.load(ownerId, controller.signal); + } catch (reason: unknown) { + read = Promise.reject(reason); + } + void read.then( + (snapshot) => { + if (request === requestFence.current && !controller.signal.aborted) + setState({ kind: 'ready', ownerId, snapshot: sanitizeAutomationSnapshot(snapshot) }); + }, + (_reason: unknown) => { + void _reason; + if (request === requestFence.current && !controller.signal.aborted) + setState((current) => ({ + kind: 'error', + ownerId, + ...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + }, + ); + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') + return ( +
+ Authentication is required before Automation data can be read for this instance. +
+ ); + + if (!dataSource) + return ( +
+ No safe Automation read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + + const retained = + state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error') + ? state.snapshot + : undefined; + if ((state.kind === 'idle' || state.kind === 'loading') && !retained) + return ( +

+ Loading Automation… +

+ ); + + if (state.kind === 'error' && !retained) + return ( +
+

Unable to load Automation: {SAFE_LOAD_ERROR}

+ +
+ ); + + const snapshot = + state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained; + if (!snapshot) return null; + return ( +
+ {state.kind === 'loading' ?

Refreshing Automation…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known Automation data.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ Automation data is {instance.freshness}; verify freshness before relying on these values. +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/instances/esim-module.test.tsx b/apps/web/src/instances/esim-module.test.tsx new file mode 100644 index 0000000..273ffd3 --- /dev/null +++ b/apps/web/src/instances/esim-module.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InstanceContext } from '../app-shell.js'; +import { EsimModule, type EsimDataSource, type EsimSnapshot } from './esim-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; +const snapshot: EsimSnapshot = { + profileCount: 4, + enabledProfileCount: 2, + lpacStatus: 'available', + workMode: 'idle', + labels: ['Provisioned', 'Enabled'], +}; + +function deferredSource() { + let resolve!: (value: EsimSnapshot) => void; + const load = vi.fn(() => new Promise((done) => (resolve = done))); + return { source: { load }, load, resolve: (value: EsimSnapshot) => resolve(value) }; +} + +describe('isolated safe eSIM read module', () => { + it('loads only through the injected source and presents safe aggregates', async () => { + const pending = deferredSource(); + render(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + const section = await screen.findByRole('region', { name: 'eSIM safe summary' }); + expect(within(section).getByText('4')).toBeTruthy(); + expect(within(section).getByText('2')).toBeTruthy(); + expect(within(section).getByText('available')).toBeTruthy(); + expect(within(section).getByText('idle')).toBeTruthy(); + expect(within(section).getByText('Provisioned')).toBeTruthy(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('has no endpoint fallback and requires the exact authenticated owner', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(await screen.findByText('Provisioned')).toBeTruthy(); + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(screen.queryByText('Provisioned')).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.getByRole('status', { name: 'eSIM unavailable' }).textContent).toMatch( + /no safe esim read data source.*will not invent or call.*endpoint/i, + ); + }); + + it('enforces a runtime presentation allowlist against untrusted extra and invalid values', async () => { + const hostile = { + ...snapshot, + profileCount: -1, + enabledProfileCount: Number.NaN, + lpacStatus: '8901-sensitive', + workMode: '', + labels: ['Enabled', 'secret-profile-name', ''], + eid: 'EID-secret', + iccid: 'ICCID-secret', + imsi: 'IMSI-secret', + msisdn: 'MSISDN-secret', + smsc: 'SMSC-secret', + smdp: 'SM-DP-secret', + matchingId: 'matching-secret', + confirmationCode: 'confirmation-secret', + imei: 'IMEI-secret', + isdpAid: 'ISDP-AID-secret', + providerData: { secret: 'raw-provider-secret' }, + } as unknown as EsimSnapshot; + render( hostile }} />); + expect(await screen.findByText('Enabled')).toBeTruthy(); + for (const forbidden of [ + 'EID-secret', + 'ICCID-secret', + 'IMSI-secret', + 'MSISDN-secret', + 'SMSC-secret', + 'SM-DP-secret', + 'matching-secret', + 'confirmation-secret', + 'IMEI-secret', + 'ISDP-AID-secret', + 'raw-provider-secret', + 'secret-profile-name', + ]) + expect(document.body.textContent).not.toContain(forbidden); + expect(document.querySelector('img')).toBeNull(); + expect(document.querySelector('script')).toBeNull(); + expect(screen.getAllByText('Unavailable')).toHaveLength(4); + }); + + it('retains same-owner data during refresh and uses fixed safe errors with retry', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('EID-secret upstream failure')) + .mockResolvedValueOnce({ ...snapshot, profileCount: 5 }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('Provisioned')).toBeTruthy(); + rerender(); + expect( + await screen.findByText(/Refresh failed; showing the last known eSIM summary/i), + ).toBeTruthy(); + expect(document.body.textContent).not.toContain('EID-secret'); + expect(screen.getByText('4')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Retry loading eSIM data' })); + expect(await screen.findByText('5')).toBeTruthy(); + }); + + it('shows a fixed initial error without leaking rejection details', async () => { + render( + { + throw 'private code'; + }, + }} + />, + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('eSIM data could not be loaded.'); + expect(alert.textContent).not.toContain('private code'); + }); + + it('aborts superseded reads and fences late prior-owner results', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((id, signal) => + id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal), + ); + const source = { load }; + const { rerender } = render(); + const alphaSignal = load.mock.calls[0]?.[1]; + rerender(); + expect(alphaSignal?.aborted).toBe(true); + alpha.resolve({ ...snapshot, labels: ['Error'] }); + bravo.resolve({ ...snapshot, labels: ['Pending'] }); + expect(await screen.findByText('Pending')).toBeTruthy(); + expect(screen.queryByText('Error')).toBeNull(); + }); + + it('caps labels at eight and defensively sanitizes unknown and null payloads', async () => { + const labels = Array.from( + { length: 12 }, + (_, index) => + (['Provisioned', 'Enabled', 'Disabled', 'Pending', 'Error'] as const)[index % 5], + ); + const { rerender } = render( + ({ labels }) }} />, + ); + const summary = await screen.findByRole('region', { name: 'eSIM safe summary' }); + expect(within(summary).getAllByRole('listitem')).toHaveLength(8); + rerender( + null }} refreshSignal={1} />, + ); + expect(await screen.findByText('No safe labels were supplied.')).toBeTruthy(); + expect(screen.getAllByText('Unavailable')).toHaveLength(4); + }); + + it('turns a synchronous source throw into the fixed initial-load error', async () => { + render( + { + throw new Error('private eSIM identifier'); + }, + }} + />, + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('eSIM data could not be loaded.'); + expect(alert.textContent).not.toContain('private eSIM identifier'); + }); +}); diff --git a/apps/web/src/instances/esim-module.tsx b/apps/web/src/instances/esim-module.tsx new file mode 100644 index 0000000..3fbcc33 --- /dev/null +++ b/apps/web/src/instances/esim-module.tsx @@ -0,0 +1,251 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Bounded status vocabularies are the only strings this module may present. */ +export type EsimLpacStatus = 'available' | 'unavailable' | 'degraded' | 'unknown'; +export type EsimWorkMode = 'idle' | 'working' | 'disabled' | 'unknown'; +export type EsimSafeLabel = 'Provisioned' | 'Enabled' | 'Disabled' | 'Pending' | 'Error'; + +/** + * Deliberately aggregate-only. Subscriber, device, provisioning, provider, and profile + * identifiers have no place in this contract. + */ +export interface EsimSnapshot { + readonly profileCount?: number | null; + readonly enabledProfileCount?: number | null; + readonly lpacStatus?: EsimLpacStatus | null; + readonly workMode?: EsimWorkMode | null; + readonly labels?: readonly EsimSafeLabel[]; +} + +export interface EsimDataSource { + /** Supplied by the authenticated owner; this module defines and calls no endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface EsimModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: EsimDataSource; + readonly refreshSignal?: unknown; +} + +type SafeSnapshot = { + profileCount?: number; + enabledProfileCount?: number; + lpacStatus?: EsimLpacStatus; + workMode?: EsimWorkMode; + labels: EsimSafeLabel[]; +}; + +type ReadState = + | { kind: 'idle'; ownerId: string } + | { kind: 'loading'; ownerId: string; snapshot?: SafeSnapshot } + | { kind: 'ready'; ownerId: string; snapshot: SafeSnapshot } + | { kind: 'error'; ownerId: string; snapshot?: SafeSnapshot }; + +const LPAC_STATUSES = new Set(['available', 'unavailable', 'degraded', 'unknown']); +const WORK_MODES = new Set(['idle', 'working', 'disabled', 'unknown']); +const SAFE_LABELS = new Set([ + 'Provisioned', + 'Enabled', + 'Disabled', + 'Pending', + 'Error', +]); +const SAFE_LOAD_ERROR = 'eSIM data could not be loaded.'; +const MAX_SAFE_LABELS = 8; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function safeCount(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +/** Copy only explicitly permitted values; never pass an injected object through to rendering. */ +function sanitizeSnapshot(value: unknown): SafeSnapshot { + const candidate = isRecord(value) ? value : {}; + const profileCount = safeCount(candidate.profileCount); + const enabledProfileCount = safeCount(candidate.enabledProfileCount); + const lpacStatus = LPAC_STATUSES.has(candidate.lpacStatus as EsimLpacStatus) + ? (candidate.lpacStatus as EsimLpacStatus) + : undefined; + const workMode = WORK_MODES.has(candidate.workMode as EsimWorkMode) + ? (candidate.workMode as EsimWorkMode) + : undefined; + const labels = Array.isArray(candidate.labels) + ? candidate.labels + .filter( + (label): label is EsimSafeLabel => + typeof label === 'string' && SAFE_LABELS.has(label as EsimSafeLabel), + ) + .slice(0, MAX_SAFE_LABELS) + : []; + return { + ...(profileCount === undefined ? {} : { profileCount }), + ...(enabledProfileCount === undefined ? {} : { enabledProfileCount }), + ...(lpacStatus === undefined ? {} : { lpacStatus }), + ...(workMode === undefined ? {} : { workMode }), + labels, + }; +} + +function display(value: number | string | undefined): string { + return value === undefined ? 'Unavailable' : String(value); +} + +function SnapshotView({ snapshot }: { snapshot: SafeSnapshot }) { + return ( +
+

eSIM safe summary

+
+
+
Profile count
+
{display(snapshot.profileCount)}
+
+
+
Enabled profile count
+
{display(snapshot.enabledProfileCount)}
+
+
+
lpac status
+
{display(snapshot.lpacStatus)}
+
+
+
Work mode
+
{display(snapshot.workMode)}
+
+
+

Safe labels

+ {snapshot.labels.length ? ( +
    + {snapshot.labels.map((label, index) => ( +
  • {label}
  • + ))} +
+ ) : ( +

No safe labels were supplied.

+ )} +
+ ); +} + +export function EsimModule({ instance, dataSource, refreshSignal }: EsimModuleProps) { + const requestOwner = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle', ownerId: instance.id }); + + useEffect(() => { + const request = ++requestOwner.current; + const ownerId = instance.id; + const controller = new AbortController(); + + if (instance.authentication !== 'authenticated' || !dataSource) { + setState({ kind: 'idle', ownerId }); + return () => controller.abort(); + } + + setState((current) => ({ + kind: 'loading', + ownerId, + ...(current.ownerId === ownerId && + (current.kind === 'ready' || current.kind === 'error') && + current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + let read: Promise; + try { + read = dataSource.load(ownerId, controller.signal); + } catch (reason: unknown) { + read = Promise.reject(reason); + } + void read.then( + (value) => { + if (request === requestOwner.current && !controller.signal.aborted) { + setState({ kind: 'ready', ownerId, snapshot: sanitizeSnapshot(value) }); + } + }, + (_reason: unknown) => { + void _reason; + if (request === requestOwner.current && !controller.signal.aborted) { + setState((current) => ({ + kind: 'error', + ownerId, + ...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + } + }, + ); + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') { + return ( +
+ Authentication is required before eSIM data can be read for this instance. +
+ ); + } + + if (!dataSource) { + return ( +
+ No safe eSIM read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + } + + const retained = + state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error') + ? state.snapshot + : undefined; + + if ((state.kind === 'idle' || state.kind === 'loading') && !retained) { + return ( +

+ Loading eSIM summary… +

+ ); + } + + if (state.kind === 'error' && !retained) { + return ( +
+

Unable to load eSIM data: {SAFE_LOAD_ERROR}

+ +
+ ); + } + + const snapshot = + state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained; + if (!snapshot) return null; + + return ( +
+ {state.kind === 'loading' ?

Refreshing eSIM summary…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known eSIM summary.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ eSIM data is {instance.freshness}; verify freshness before relying on these values. +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/instances/notifications-module.test.tsx b/apps/web/src/instances/notifications-module.test.tsx new file mode 100644 index 0000000..1a26a45 --- /dev/null +++ b/apps/web/src/instances/notifications-module.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InstanceContext } from '../app-shell.js'; +import { + NotificationsModule, + type NotificationsDataSource, + type NotificationsSnapshot, +} from './notifications-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: NotificationsSnapshot = { + observedAt: '2026-07-17T12:00:00Z', + channels: { + status: 'healthy', + total: 5, + enabled: 4, + disabled: 1, + healthy: 3, + degraded: 1, + failed: 0, + endpoint: 'https://private.example/hook', + destination: '+15550000123', + token: 'channel-secret', + config: { provider: 'private-provider' }, + }, + queue: { + status: 'processing', + total: 18, + pending: 4, + processing: 2, + delivered: 11, + failed: 1, + payload: 'private queue payload', + destination: 'private@example.test', + }, + logs: { + status: 'available', + total: 30, + info: 20, + warning: 7, + error: 3, + rawLogs: ['private raw log line'], + phoneNumber: '+15550000456', + }, +}; + +function deferredSource() { + let resolve!: (value: unknown) => void; + let reject!: (reason: unknown) => void; + const load = vi.fn( + (_instanceId, _signal) => + new Promise((done, fail) => { + void _instanceId; + void _signal; + resolve = done; + reject = fail; + }), + ); + return { + source: { load }, + load, + resolve: (value: unknown) => resolve(value), + reject: (reason: unknown) => reject(reason), + }; +} + +describe('Notifications isolated safe read module', () => { + it('reads only through the injected exact-owner source and renders allowlisted aggregates', async () => { + const pending = deferredSource(); + render(); + + expect(screen.getByRole('status', { name: 'Notifications loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + const channels = await screen.findByRole('region', { name: 'Channel aggregate' }); + expect(within(channels).getByText('healthy')).toBeTruthy(); + expect(within(channels).getByText('5')).toBeTruthy(); + expect( + within(screen.getByRole('region', { name: 'Queue aggregate' })).getByText('18'), + ).toBeTruthy(); + expect( + within(screen.getByRole('region', { name: 'Log aggregate' })).getByText('30'), + ).toBeTruthy(); + expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy(); + }); + + it('never exposes polymorphic config, endpoints, destinations, tokens, phone numbers, payloads, raw logs, or actions', async () => { + render( snapshot }} />); + expect(await screen.findByText(/aggregate counts and status only/i)).toBeTruthy(); + + const text = document.body.textContent ?? ''; + for (const secret of [ + 'https://private.example/hook', + '+15550000123', + 'channel-secret', + 'private-provider', + 'private queue payload', + 'private@example.test', + 'private raw log line', + '+15550000456', + ]) + expect(text).not.toContain(secret); + expect( + screen.queryByText(/endpoint|destination|token|phone number|payload|raw log/i), + ).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('fails closed without an injected source or authenticated owner', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(screen.getByRole('status', { name: 'Notifications unavailable' }).textContent).toMatch( + /no safe notifications read data source.*uncontracted production endpoint/i, + ); + + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(load).not.toHaveBeenCalled(); + }); + + it('clears prior-owner data, aborts replaced reads, and fences late responses', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((id, signal) => + id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal), + ); + const source = { load }; + const { rerender } = render(); + const alphaSignal = load.mock.calls[0]?.[1]; + + rerender( + , + ); + expect(alphaSignal?.aborted).toBe(true); + alpha.resolve(snapshot); + bravo.resolve({ + ...snapshot, + channels: { ...snapshot.channels, status: 'degraded', total: 9 }, + }); + expect(await screen.findByText('degraded')).toBeTruthy(); + expect(screen.queryByText('healthy')).toBeNull(); + }); + + it('uses fixed errors and retains only same-owner last-good data across refresh failure and retry', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('secret endpoint and token')) + .mockResolvedValueOnce({ ...snapshot, channels: { status: 'available', total: 6 } }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('healthy')).toBeTruthy(); + + rerender(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/refresh failed; showing the last known notifications data/i); + expect(alert.textContent).not.toContain('secret endpoint'); + expect(screen.getByText('healthy')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Retry loading Notifications' })); + expect( + await within(screen.getByRole('region', { name: 'Channel aggregate' })).findByText( + 'available', + ), + ).toBeTruthy(); + }); + + it('copies a bounded safe snapshot and drops malformed nested data and nominal-field secrets', async () => { + const unsafe = { + observedAt: 'api-token@example.test', + channels: { + status: 'channel-secret', + total: -1, + enabled: 1.5, + disabled: 2, + healthy: Number.POSITIVE_INFINITY, + }, + queue: ['private payload'], + logs: { status: { token: 'nested-secret' }, total: Number.MAX_SAFE_INTEGER + 1, error: 3 }, + }; + render( unsafe }} />); + const logs = await screen.findByRole('region', { name: 'Log aggregate' }); + expect(within(logs).getByText('3')).toBeTruthy(); + expect( + within(screen.getByRole('region', { name: 'Channel aggregate' })).getByText('2'), + ).toBeTruthy(); + expect(document.body.textContent).not.toMatch( + /api-token|channel-secret|private payload|nested-secret|Infinity|1\.5|-1/, + ); + expect(screen.queryByText(/^Observed /)).toBeNull(); + unsafe.channels.disabled = 99; + expect(screen.queryByText('99')).toBeNull(); + }); + + it('uses a fixed initial-load error without rejection details', async () => { + render( + Promise.reject(new Error('raw log and private token')) }} + />, + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Notifications data could not be loaded.'); + expect(alert.textContent).not.toContain('private token'); + }); + + it('turns a synchronous source throw into the fixed initial-load error', async () => { + render( + { + throw new Error('private notification token'); + }, + }} + />, + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Notifications data could not be loaded.'); + expect(alert.textContent).not.toContain('private notification token'); + }); +}); diff --git a/apps/web/src/instances/notifications-module.tsx b/apps/web/src/instances/notifications-module.tsx new file mode 100644 index 0000000..b54c198 --- /dev/null +++ b/apps/web/src/instances/notifications-module.tsx @@ -0,0 +1,343 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Values permitted at the Notifications presentation boundary. */ +export type NotificationStatus = + | 'healthy' + | 'degraded' + | 'failed' + | 'available' + | 'unavailable' + | 'idle' + | 'processing' + | 'paused'; +export type NotificationAggregateValue = NotificationStatus | number | null; + +/** Aggregate channel counts/status only. All source-specific details are ignored. */ +export interface NotificationChannelAggregate { + readonly status?: NotificationStatus | null; + readonly total?: number | null; + readonly enabled?: number | null; + readonly disabled?: number | null; + readonly healthy?: number | null; + readonly degraded?: number | null; + readonly failed?: number | null; + readonly [sourceField: string]: unknown; +} + +/** Aggregate queue counts/status only. Individual jobs and their payloads are unsupported. */ +export interface NotificationQueueAggregate { + readonly status?: NotificationStatus | null; + readonly total?: number | null; + readonly pending?: number | null; + readonly processing?: number | null; + readonly delivered?: number | null; + readonly failed?: number | null; + readonly [sourceField: string]: unknown; +} + +/** Aggregate log counts/status only. Raw records are unsupported. */ +export interface NotificationLogAggregate { + readonly status?: NotificationStatus | null; + readonly total?: number | null; + readonly info?: number | null; + readonly warning?: number | null; + readonly error?: number | null; + readonly [sourceField: string]: unknown; +} + +export interface NotificationsSnapshot { + readonly observedAt?: string; + readonly channels: NotificationChannelAggregate; + readonly queue: NotificationQueueAggregate; + readonly logs: NotificationLogAggregate; +} + +export interface NotificationsDataSource { + /** Supplied by the authenticated owner; this module defines and calls no network endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface NotificationsModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: NotificationsDataSource; + /** Change this owner-provided value to request another read. */ + readonly refreshSignal?: unknown; +} + +type OwnedSnapshot = { readonly ownerId: string; readonly value: NotificationsSnapshot }; +type ReadState = + | { kind: 'idle' } + | { kind: 'loading'; snapshot?: OwnedSnapshot } + | { kind: 'ready'; snapshot: OwnedSnapshot } + | { kind: 'error'; snapshot?: OwnedSnapshot }; + +const SAFE_LOAD_ERROR = 'Notifications data could not be loaded.'; +const NOTIFICATION_STATUSES = new Set([ + 'healthy', + 'degraded', + 'failed', + 'available', + 'unavailable', + 'idle', + 'processing', + 'paused', +]); +const COUNT_KEYS = { + channels: ['total', 'enabled', 'disabled', 'healthy', 'degraded', 'failed'], + queue: ['total', 'pending', 'processing', 'delivered', 'failed'], + logs: ['total', 'info', 'warning', 'error'], +} as const; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function safeTimestamp(value: unknown): string | undefined { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + ) + return undefined; + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) return undefined; + const canonical = parsed.toISOString(); + return canonical === value || canonical === value.replace(/Z$/, '.000Z') ? value : undefined; +} + +function sanitizeAggregate( + value: unknown, + countKeys: readonly string[], +): Record { + const source = isRecord(value) ? value : {}; + const safe: Record = {}; + if (source.status === null) safe.status = null; + else if ( + typeof source.status === 'string' && + NOTIFICATION_STATUSES.has(source.status as NotificationStatus) + ) + safe.status = source.status as NotificationStatus; + for (const key of countKeys) { + const count = source[key]; + if (count === null || (Number.isSafeInteger(count) && (count as number) >= 0)) + safe[key] = count as number | null; + } + return safe; +} + +/** Copy and validate untrusted source data before it can enter React state. */ +export function sanitizeNotificationsSnapshot(value: unknown): NotificationsSnapshot { + const source = isRecord(value) ? value : {}; + const observedAt = safeTimestamp(source.observedAt); + return { + ...(observedAt ? { observedAt } : {}), + channels: sanitizeAggregate(source.channels, COUNT_KEYS.channels), + queue: sanitizeAggregate(source.queue, COUNT_KEYS.queue), + logs: sanitizeAggregate(source.logs, COUNT_KEYS.logs), + }; +} + +const CHANNEL_FIELDS = [ + ['Status', 'status'], + ['Total', 'total'], + ['Enabled', 'enabled'], + ['Disabled', 'disabled'], + ['Healthy', 'healthy'], + ['Degraded', 'degraded'], + ['Failed', 'failed'], +] as const; + +const QUEUE_FIELDS = [ + ['Status', 'status'], + ['Total', 'total'], + ['Pending', 'pending'], + ['Processing', 'processing'], + ['Delivered', 'delivered'], + ['Failed', 'failed'], +] as const; + +const LOG_FIELDS = [ + ['Status', 'status'], + ['Total', 'total'], + ['Info', 'info'], + ['Warning', 'warning'], + ['Error', 'error'], +] as const; + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +function AggregateFields({ + aggregate, + fields, +}: { + aggregate: Readonly>; + fields: readonly (readonly [label: string, key: string])[]; +}) { + // Constant-key projection is the security boundary: never enumerate or spread source objects. + const supplied = fields.filter(([, key]) => aggregate[key] !== undefined); + if (!supplied.length) return

No aggregate status was supplied.

; + + return ( +
+ {supplied.map(([label, key]) => { + const value = aggregate[key] as NotificationAggregateValue; + return ( +
+
{label}
+
{value == null ? 'Unavailable' : String(value)}
+
+ ); + })} +
+ ); +} + +function SnapshotView({ snapshot }: { snapshot: NotificationsSnapshot }) { + return ( + <> + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +

Aggregate counts and status only; notification details and contents are excluded.

+
+
+ +
+
+ +
+
+ +
+
+ + ); +} + +export function NotificationsModule({ + instance, + dataSource, + refreshSignal, +}: NotificationsModuleProps) { + const requestFence = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle' }); + + useEffect(() => { + const request = ++requestFence.current; + const ownerId = instance.id; + const controller = new AbortController(); + + if (instance.authentication !== 'authenticated' || !dataSource) { + setState({ kind: 'idle' }); + return () => controller.abort(); + } + + setState((current) => { + const retained = + current.kind !== 'idle' && current.snapshot?.ownerId === ownerId + ? current.snapshot + : undefined; + return { kind: 'loading', ...(retained ? { snapshot: retained } : {}) }; + }); + + let read: Promise; + try { + read = dataSource.load(ownerId, controller.signal); + } catch (reason: unknown) { + read = Promise.reject(reason); + } + void read.then( + (value) => { + if (request === requestFence.current && !controller.signal.aborted) { + setState({ + kind: 'ready', + snapshot: { ownerId, value: sanitizeNotificationsSnapshot(value) }, + }); + } + }, + (_reason: unknown) => { + void _reason; + if (request === requestFence.current && !controller.signal.aborted) { + setState((current) => { + const retained = + current.kind === 'loading' && current.snapshot?.ownerId === ownerId + ? current.snapshot + : undefined; + return { kind: 'error', ...(retained ? { snapshot: retained } : {}) }; + }); + } + }, + ); + + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') { + return ( +
+ Authentication is required before Notifications data can be read for this instance. +
+ ); + } + + if (!dataSource) { + return ( +
+ No safe Notifications read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + } + + const snapshot = + state.kind !== 'idle' && state.snapshot?.ownerId === instance.id ? state.snapshot : undefined; + + if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) { + return ( +

+ Loading Notifications… +

+ ); + } + + if (state.kind === 'error' && !snapshot) { + return ( +
+

Unable to load Notifications: {SAFE_LOAD_ERROR}

+ +
+ ); + } + + if (!snapshot) return null; + + return ( +
+ {state.kind === 'loading' ?

Refreshing Notifications…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known Notifications data.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ Notifications data is {instance.freshness}; verify freshness before relying on these + values. +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/instances/ota-module.test.tsx b/apps/web/src/instances/ota-module.test.tsx new file mode 100644 index 0000000..1f6f969 --- /dev/null +++ b/apps/web/src/instances/ota-module.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InstanceContext } from '../app-shell.js'; +import { OtaModule, type OtaDataSource, type OtaSnapshot } from './ota-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: OtaSnapshot = { + currentVersion: 'v2.4.1-build.7', + status: 'downloading', + progressPercent: 42, + updateAvailable: true, +}; + +function deferredSource() { + let resolve!: (value: OtaSnapshot) => void; + const load = vi.fn( + (_instanceId, _signal) => + new Promise((done) => { + void _instanceId; + void _signal; + resolve = done; + }), + ); + return { source: { load }, load, resolve: (value: OtaSnapshot) => resolve(value) }; +} + +describe('isolated safe OTA read module', () => { + it('reads only through the injected exact-owner source and renders allowlisted OTA metadata', async () => { + const pending = deferredSource(); + render(); + + expect(screen.getByRole('status', { name: 'OTA loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + const summary = await screen.findByRole('region', { name: 'OTA safe summary' }); + expect(within(summary).getByText('v2.4.1-build.7')).toBeTruthy(); + expect(within(summary).getByText('downloading')).toBeTruthy(); + expect(within(summary).getByText('42%')).toBeTruthy(); + expect(within(summary).getByText('Available')).toBeTruthy(); + }); + + it('strictly projects safe values and rejects URLs, proxy prefixes, notes, logs, and file paths', async () => { + const unsafe = { + ...snapshot, + currentVersion: 'https://private.example/releases/secret', + status: 'proxy:/admin/ota', + endpoint: 'https://private.example/ota', + proxyPrefix: '/api/proxy/alpha', + releaseNotes: 'private release notes', + rawLogs: ['private raw OTA log'], + filePath: '/var/lib/private/update.bin', + uploadPath: '/tmp/private.bin', + } as unknown as OtaSnapshot; + render( unsafe }} />); + + const summary = await screen.findByRole('region', { name: 'OTA safe summary' }); + expect(within(summary).getAllByText('Unavailable')).toHaveLength(2); + expect(document.body.textContent).not.toMatch( + /private\.example|admin\/ota|api\/proxy|private release notes|private raw OTA log|var\/lib|tmp\/private/i, + ); + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('offers no endpoint or apply, cancel, or upload operations', async () => { + render( snapshot }} />); + expect(await screen.findByText('v2.4.1-build.7')).toBeTruthy(); + expect(screen.getByText(/read-only OTA metadata/i)).toBeTruthy(); + expect(screen.queryByText(/endpoint/i)).toBeNull(); + expect(screen.queryByRole('button', { name: /apply|cancel|upload/i })).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + it('fails closed without an injected source or authenticated owner', () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(screen.getByRole('status', { name: 'OTA unavailable' }).textContent).toMatch( + /no safe OTA read data source.*will not invent.*production endpoint/i, + ); + + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(load).not.toHaveBeenCalled(); + }); + + it('aborts superseded reads, clears the old owner, and fences late results', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((id, signal) => + id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal), + ); + const source = { load }; + const { rerender } = render(); + const alphaSignal = load.mock.calls[0]?.[1]; + + rerender(); + expect(alphaSignal?.aborted).toBe(true); + alpha.resolve({ ...snapshot, currentVersion: 'v1.0.0-alpha' }); + bravo.resolve({ ...snapshot, currentVersion: 'v3.0.0-bravo' }); + expect(await screen.findByText('v3.0.0-bravo')).toBeTruthy(); + expect(screen.queryByText('v1.0.0-alpha')).toBeNull(); + }); + + it('retains only same-owner last-good data on refresh failure and retries with fixed errors', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('secret URL /var/private/update.bin')) + .mockResolvedValueOnce({ ...snapshot, currentVersion: 'v2.4.2' }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('v2.4.1-build.7')).toBeTruthy(); + + rerender(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/refresh failed; showing the last known OTA data/i); + expect(alert.textContent).not.toMatch(/secret URL|var\/private/i); + expect(screen.getByText('v2.4.1-build.7')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Retry loading OTA data' })); + expect(await screen.findByText('v2.4.2')).toBeTruthy(); + }); + + it('uses a fixed initial-load error without rejection details', async () => { + render( + Promise.reject(new Error('private release URL')) }} + />, + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('OTA data could not be loaded.'); + expect(alert.textContent).not.toContain('private release URL'); + }); + + it('accepts only bounded semver-like versions and handles unknown or null payloads', async () => { + const opaque = 'release-token-0123456789abcdef'; + const { rerender } = render( + ({ currentVersion: opaque }) }} + />, + ); + let summary = await screen.findByRole('region', { name: 'OTA safe summary' }); + expect(within(summary).getAllByText('Unavailable')).toHaveLength(4); + expect(document.body.textContent).not.toContain(opaque); + rerender( + null }} refreshSignal={1} />, + ); + summary = await screen.findByRole('region', { name: 'OTA safe summary' }); + expect(within(summary).getAllByText('Unavailable')).toHaveLength(4); + }); +}); diff --git a/apps/web/src/instances/ota-module.tsx b/apps/web/src/instances/ota-module.tsx new file mode 100644 index 0000000..8d49862 --- /dev/null +++ b/apps/web/src/instances/ota-module.tsx @@ -0,0 +1,268 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +export type OtaStatus = + | 'idle' + | 'checking' + | 'up-to-date' + | 'available' + | 'downloading' + | 'verifying' + | 'installing' + | 'rebooting' + | 'completed' + | 'failed' + | 'unknown'; + +/** + * The complete OTA presentation contract. Unknown source properties are deliberately tolerated at + * the type boundary but are never copied into render state. + */ +export interface OtaSnapshot { + readonly currentVersion?: string | null; + readonly status?: OtaStatus | null; + readonly progressPercent?: number | null; + readonly updateAvailable?: boolean | null; + readonly [sourceField: string]: unknown; +} + +export interface OtaDataSource { + /** Supplied by the authenticated owner; this module defines and calls no network endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface OtaModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: OtaDataSource; + /** Change this owner-provided value to request another read. */ + readonly refreshSignal?: unknown; +} + +type SafeOtaSnapshot = { + readonly currentVersion?: string; + readonly status?: OtaStatus; + readonly progressPercent?: number; + readonly updateAvailable?: boolean; +}; + +type ReadState = + | { kind: 'idle'; ownerId: string } + | { kind: 'loading'; ownerId: string; snapshot?: SafeOtaSnapshot } + | { kind: 'ready'; ownerId: string; snapshot: SafeOtaSnapshot } + | { kind: 'error'; ownerId: string; snapshot?: SafeOtaSnapshot }; + +const SAFE_LOAD_ERROR = 'OTA data could not be loaded.'; +const OTA_STATUSES = new Set([ + 'idle', + 'checking', + 'up-to-date', + 'available', + 'downloading', + 'verifying', + 'installing', + 'rebooting', + 'completed', + 'failed', + 'unknown', +]); + +/** Permit only a bounded semver-like version, never an arbitrary opaque source token. */ +function safeVersion(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length > 80) return undefined; + return /^v?(?:0|[1-9]\d{0,5})\.(?:0|[1-9]\d{0,5})\.(?:0|[1-9]\d{0,5})(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.test( + value, + ) + ? value + : undefined; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function sanitizeSnapshot(value: unknown): SafeOtaSnapshot { + const source = isRecord(value) ? value : {}; + const currentVersion = safeVersion(source.currentVersion); + const status = OTA_STATUSES.has(source.status as OtaStatus) + ? (source.status as OtaStatus) + : undefined; + const progressPercent = + typeof source.progressPercent === 'number' && + Number.isFinite(source.progressPercent) && + source.progressPercent >= 0 && + source.progressPercent <= 100 + ? source.progressPercent + : undefined; + const updateAvailable = + typeof source.updateAvailable === 'boolean' ? source.updateAvailable : undefined; + + // Constant-key primitive projection is the security boundary. Never enumerate or retain source. + return { + ...(currentVersion === undefined ? {} : { currentVersion }), + ...(status === undefined ? {} : { status }), + ...(progressPercent === undefined ? {} : { progressPercent }), + ...(updateAvailable === undefined ? {} : { updateAvailable }), + }; +} + +function display(value: string | undefined): string { + return value ?? 'Unavailable'; +} + +function SnapshotView({ snapshot }: { snapshot: SafeOtaSnapshot }) { + return ( +
+

OTA safe summary

+

Read-only OTA metadata: update operations and source-specific details are excluded.

+
+
+
Current version
+
{display(snapshot.currentVersion)}
+
+
+
Status
+
{display(snapshot.status)}
+
+
+
Progress
+
+ {snapshot.progressPercent === undefined + ? 'Unavailable' + : `${snapshot.progressPercent}%`} +
+
+
+
Update availability
+
+ {snapshot.updateAvailable === undefined + ? 'Unavailable' + : snapshot.updateAvailable + ? 'Available' + : 'No update available'} +
+
+
+
+ ); +} + +export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProps) { + const requestFence = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle', ownerId: instance.id }); + + useEffect(() => { + const request = ++requestFence.current; + const ownerId = instance.id; + const controller = new AbortController(); + + if (instance.authentication !== 'authenticated' || !dataSource) { + setState({ kind: 'idle', ownerId }); + return () => controller.abort(); + } + + setState((current) => ({ + kind: 'loading', + ownerId, + ...(current.ownerId === ownerId && + (current.kind === 'ready' || current.kind === 'error') && + current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + + let read: Promise; + try { + read = dataSource.load(ownerId, controller.signal); + } catch (reason: unknown) { + read = Promise.reject(reason); + } + void read.then( + (value) => { + if (request === requestFence.current && !controller.signal.aborted) { + setState({ kind: 'ready', ownerId, snapshot: sanitizeSnapshot(value) }); + } + }, + (_reason: unknown) => { + void _reason; + if (request === requestFence.current && !controller.signal.aborted) { + setState((current) => ({ + kind: 'error', + ownerId, + ...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + } + }, + ); + + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') { + return ( +
+ Authentication is required before OTA data can be read for this instance. +
+ ); + } + + if (!dataSource) { + return ( +
+ No safe OTA read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + } + + const retained = + state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error') + ? state.snapshot + : undefined; + + if ((state.kind === 'idle' || state.kind === 'loading') && !retained) { + return ( +

+ Loading OTA data… +

+ ); + } + + if (state.kind === 'error' && !retained) { + return ( +
+

Unable to load OTA data: {SAFE_LOAD_ERROR}

+ +
+ ); + } + + const snapshot = + state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained; + if (!snapshot) return null; + + return ( +
+ {state.kind === 'loading' ?

Refreshing OTA data…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known OTA data.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ OTA data is {instance.freshness}; verify freshness before relying on these values. +

+ ) : null} + +
+ ); +}