feat(web): complete capability-driven instance modules
This commit is contained in:
@@ -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: (
|
||||
<EsimModule
|
||||
instance={instance}
|
||||
{...(esimDataSource ? { dataSource: esimDataSource } : {})}
|
||||
refreshSignal={detailRefreshSignal}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(module === 'notifications' && instance
|
||||
? {
|
||||
moduleContent: (
|
||||
<NotificationsModule
|
||||
instance={instance}
|
||||
{...(notificationsDataSource ? { dataSource: notificationsDataSource } : {})}
|
||||
refreshSignal={detailRefreshSignal}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(module === 'automation' && instance
|
||||
? {
|
||||
moduleContent: (
|
||||
<AutomationModule
|
||||
instance={instance}
|
||||
{...(automationDataSource ? { dataSource: automationDataSource } : {})}
|
||||
refreshSignal={detailRefreshSignal}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(module === 'ota' && instance
|
||||
? {
|
||||
moduleContent: (
|
||||
<OtaModule
|
||||
instance={instance}
|
||||
{...(otaDataSource ? { dataSource: otaDataSource } : {})}
|
||||
refreshSignal={detailRefreshSignal}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AutomationDataSource['load']>(
|
||||
(_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(<AutomationModule instance={owner} dataSource={pending.source} />);
|
||||
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(<AutomationModule instance={owner} dataSource={{ load: async () => 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',
|
||||
'<script>secret</script>',
|
||||
'x'.repeat(33),
|
||||
42,
|
||||
],
|
||||
};
|
||||
render(<AutomationModule instance={owner} dataSource={{ load: async () => 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<AutomationDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(
|
||||
<AutomationModule
|
||||
instance={{ ...owner, authentication: 'auth-required' }}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<AutomationModule instance={owner} />);
|
||||
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<AutomationDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('secret endpoint token'))
|
||||
.mockResolvedValueOnce({ ...snapshot, tasks: { ...snapshot.tasks, total: 13 } });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<AutomationModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
const counts = await screen.findByRole('region', { name: 'Task counts' });
|
||||
expect(within(counts).getByText('12')).toBeTruthy();
|
||||
rerender(<AutomationModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
|
||||
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<AutomationDataSource['load']>((id, signal) =>
|
||||
id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<AutomationModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
rerender(
|
||||
<AutomationModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
|
||||
);
|
||||
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(
|
||||
<AutomationModule
|
||||
instance={owner}
|
||||
dataSource={{
|
||||
load: () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
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<AutomationState>(['healthy', 'degraded', 'failed', 'disabled', 'unknown']);
|
||||
const SCHEDULERS = new Set<AutomationScheduler>([
|
||||
'active',
|
||||
'idle',
|
||||
'paused',
|
||||
'disabled',
|
||||
'unavailable',
|
||||
]);
|
||||
const WORKERS = new Set<AutomationWorkers>([
|
||||
'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<Record<string, unknown>> {
|
||||
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<T extends string>(
|
||||
value: unknown,
|
||||
allowed: ReadonlySet<T>,
|
||||
): 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<string, number | null> = {};
|
||||
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 (
|
||||
<section className="automation-card" aria-label={label}>
|
||||
<h2>{label}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Fields({
|
||||
values,
|
||||
}: {
|
||||
values: readonly (readonly [string, string | number | null | undefined])[];
|
||||
}) {
|
||||
const supplied = values.filter(([, value]) => value !== undefined);
|
||||
if (!supplied.length) return <p>No aggregate status was supplied.</p>;
|
||||
return (
|
||||
<dl>
|
||||
{supplied.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotView({ snapshot }: { snapshot: AutomationSnapshot }) {
|
||||
const status = snapshot.status;
|
||||
const tasks = snapshot.tasks;
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<p>Read-only aggregate view; task details and operational controls are excluded.</p>
|
||||
<div className="automation-grid">
|
||||
<Section label="Automation status">
|
||||
<Fields
|
||||
values={[
|
||||
['State', status.state],
|
||||
['Scheduler', status.scheduler],
|
||||
['Workers', status.workers],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section label="Task counts">
|
||||
<Fields
|
||||
values={[
|
||||
['Total', tasks.total],
|
||||
['Enabled', tasks.enabled],
|
||||
['Disabled', tasks.disabled],
|
||||
['Running', tasks.running],
|
||||
['Queued', tasks.queued],
|
||||
['Succeeded', tasks.succeeded],
|
||||
['Failed', tasks.failed],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutomationModule({ instance, dataSource, refreshSignal }: AutomationModuleProps) {
|
||||
const requestFence = useRef(0);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [state, setState] = useState<ReadState>({ 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<unknown>;
|
||||
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 (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Automation data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Automation unavailable">
|
||||
No safe Automation read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
</div>
|
||||
);
|
||||
|
||||
const retained =
|
||||
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
|
||||
? state.snapshot
|
||||
: undefined;
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retained)
|
||||
return (
|
||||
<p role="status" aria-label="Automation loading status">
|
||||
Loading Automation…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !retained)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Automation: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Automation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const snapshot =
|
||||
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained;
|
||||
if (!snapshot) return null;
|
||||
return (
|
||||
<div className="automation-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Automation…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Automation data.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Automation
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Automation freshness">
|
||||
Automation data is {instance.freshness}; verify freshness before relying on these values.
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<EsimDataSource['load']>(() => 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(<EsimModule instance={owner} dataSource={pending.source} />);
|
||||
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<EsimDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<EsimModule instance={owner} dataSource={{ load }} />);
|
||||
expect(await screen.findByText('Provisioned')).toBeTruthy();
|
||||
rerender(
|
||||
<EsimModule
|
||||
instance={{
|
||||
...owner,
|
||||
id: 'bravo',
|
||||
authentication: 'auth-required',
|
||||
status: 'auth-required',
|
||||
}}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.queryByText('Provisioned')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<EsimModule instance={owner} />);
|
||||
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: '<img src=x onerror=alert(1)>',
|
||||
labels: ['Enabled', 'secret-profile-name', '<script>alert(1)</script>'],
|
||||
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(<EsimModule instance={owner} dataSource={{ load: async () => 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<EsimDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('EID-secret upstream failure'))
|
||||
.mockResolvedValueOnce({ ...snapshot, profileCount: 5 });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<EsimModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('Provisioned')).toBeTruthy();
|
||||
rerender(<EsimModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
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(
|
||||
<EsimModule
|
||||
instance={owner}
|
||||
dataSource={{
|
||||
load: async () => {
|
||||
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<EsimDataSource['load']>((id, signal) =>
|
||||
id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<EsimModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
rerender(<EsimModule instance={{ ...owner, id: 'bravo' }} dataSource={source} />);
|
||||
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(
|
||||
<EsimModule instance={owner} dataSource={{ load: async () => ({ labels }) }} />,
|
||||
);
|
||||
const summary = await screen.findByRole('region', { name: 'eSIM safe summary' });
|
||||
expect(within(summary).getAllByRole('listitem')).toHaveLength(8);
|
||||
rerender(
|
||||
<EsimModule instance={owner} dataSource={{ load: async () => 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(
|
||||
<EsimModule
|
||||
instance={owner}
|
||||
dataSource={{
|
||||
load: () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
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<EsimLpacStatus>(['available', 'unavailable', 'degraded', 'unknown']);
|
||||
const WORK_MODES = new Set<EsimWorkMode>(['idle', 'working', 'disabled', 'unknown']);
|
||||
const SAFE_LABELS = new Set<EsimSafeLabel>([
|
||||
'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<Record<string, unknown>> {
|
||||
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 (
|
||||
<section className="esim-card" aria-label="eSIM safe summary">
|
||||
<h2>eSIM safe summary</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Profile count</dt>
|
||||
<dd>{display(snapshot.profileCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Enabled profile count</dt>
|
||||
<dd>{display(snapshot.enabledProfileCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>lpac status</dt>
|
||||
<dd>{display(snapshot.lpacStatus)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Work mode</dt>
|
||||
<dd>{display(snapshot.workMode)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>Safe labels</h3>
|
||||
{snapshot.labels.length ? (
|
||||
<ul>
|
||||
{snapshot.labels.map((label, index) => (
|
||||
<li key={`${label}-${index}`}>{label}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No safe labels were supplied.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function EsimModule({ instance, dataSource, refreshSignal }: EsimModuleProps) {
|
||||
const requestOwner = useRef(0);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [state, setState] = useState<ReadState>({ 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<unknown>;
|
||||
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 (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before eSIM data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="eSIM unavailable">
|
||||
No safe eSIM read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const retained =
|
||||
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
|
||||
? state.snapshot
|
||||
: undefined;
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) {
|
||||
return (
|
||||
<p role="status" aria-label="eSIM loading status">
|
||||
Loading eSIM summary…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'error' && !retained) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load eSIM data: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading eSIM data
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained;
|
||||
if (!snapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="esim-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing eSIM summary…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known eSIM summary.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading eSIM data
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="eSIM freshness">
|
||||
eSIM data is {instance.freshness}; verify freshness before relying on these values.
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<NotificationsDataSource['load']>(
|
||||
(_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(<NotificationsModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
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(<NotificationsModule instance={owner} dataSource={{ load: async () => 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<NotificationsDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<NotificationsModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Notifications unavailable' }).textContent).toMatch(
|
||||
/no safe notifications read data source.*uncontracted production endpoint/i,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<NotificationsModule
|
||||
instance={{ ...owner, authentication: 'auth-required' }}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
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<NotificationsDataSource['load']>((id, signal) =>
|
||||
id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<NotificationsModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
|
||||
rerender(
|
||||
<NotificationsModule
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||
dataSource={source}
|
||||
/>,
|
||||
);
|
||||
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<NotificationsDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('secret endpoint and token'))
|
||||
.mockResolvedValueOnce({ ...snapshot, channels: { status: 'available', total: 6 } });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<NotificationsModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('healthy')).toBeTruthy();
|
||||
|
||||
rerender(<NotificationsModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
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(<NotificationsModule instance={owner} dataSource={{ load: async () => 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(
|
||||
<NotificationsModule
|
||||
instance={owner}
|
||||
dataSource={{ load: async () => 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(
|
||||
<NotificationsModule
|
||||
instance={owner}
|
||||
dataSource={{
|
||||
load: () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
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<NotificationStatus>([
|
||||
'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<Record<string, unknown>> {
|
||||
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<string, NotificationAggregateValue> {
|
||||
const source = isRecord(value) ? value : {};
|
||||
const safe: Record<string, NotificationAggregateValue> = {};
|
||||
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 (
|
||||
<section className="notifications-card" aria-label={label}>
|
||||
<h2>{label}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AggregateFields({
|
||||
aggregate,
|
||||
fields,
|
||||
}: {
|
||||
aggregate: Readonly<Record<string, unknown>>;
|
||||
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 <p>No aggregate status was supplied.</p>;
|
||||
|
||||
return (
|
||||
<dl>
|
||||
{supplied.map(([label, key]) => {
|
||||
const value = aggregate[key] as NotificationAggregateValue;
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotView({ snapshot }: { snapshot: NotificationsSnapshot }) {
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<p>Aggregate counts and status only; notification details and contents are excluded.</p>
|
||||
<div className="notifications-grid">
|
||||
<Section label="Channel aggregate">
|
||||
<AggregateFields aggregate={snapshot.channels} fields={CHANNEL_FIELDS} />
|
||||
</Section>
|
||||
<Section label="Queue aggregate">
|
||||
<AggregateFields aggregate={snapshot.queue} fields={QUEUE_FIELDS} />
|
||||
</Section>
|
||||
<Section label="Log aggregate">
|
||||
<AggregateFields aggregate={snapshot.logs} fields={LOG_FIELDS} />
|
||||
</Section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationsModule({
|
||||
instance,
|
||||
dataSource,
|
||||
refreshSignal,
|
||||
}: NotificationsModuleProps) {
|
||||
const requestFence = useRef(0);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [state, setState] = useState<ReadState>({ 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<unknown>;
|
||||
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 (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before Notifications data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Notifications unavailable">
|
||||
No safe Notifications read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
state.kind !== 'idle' && state.snapshot?.ownerId === instance.id ? state.snapshot : undefined;
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) {
|
||||
return (
|
||||
<p role="status" aria-label="Notifications loading status">
|
||||
Loading Notifications…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'error' && !snapshot) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Notifications: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Notifications
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="notifications-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Notifications…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Notifications data.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Notifications
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Notifications freshness">
|
||||
Notifications data is {instance.freshness}; verify freshness before relying on these
|
||||
values.
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot.value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<OtaDataSource['load']>(
|
||||
(_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(<OtaModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
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(<OtaModule instance={owner} dataSource={{ load: async () => 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(<OtaModule instance={owner} dataSource={{ load: async () => 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<OtaDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<OtaModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'OTA unavailable' }).textContent).toMatch(
|
||||
/no safe OTA read data source.*will not invent.*production endpoint/i,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<OtaModule instance={{ ...owner, authentication: 'auth-required' }} dataSource={{ load }} />,
|
||||
);
|
||||
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<OtaDataSource['load']>((id, signal) =>
|
||||
id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<OtaModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
|
||||
rerender(<OtaModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />);
|
||||
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<OtaDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('secret URL /var/private/update.bin'))
|
||||
.mockResolvedValueOnce({ ...snapshot, currentVersion: 'v2.4.2' });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<OtaModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('v2.4.1-build.7')).toBeTruthy();
|
||||
|
||||
rerender(<OtaModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
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(
|
||||
<OtaModule
|
||||
instance={owner}
|
||||
dataSource={{ load: async () => 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(
|
||||
<OtaModule
|
||||
instance={owner}
|
||||
dataSource={{ load: async () => ({ 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(
|
||||
<OtaModule instance={owner} dataSource={{ load: async () => null }} refreshSignal={1} />,
|
||||
);
|
||||
summary = await screen.findByRole('region', { name: 'OTA safe summary' });
|
||||
expect(within(summary).getAllByText('Unavailable')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
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<OtaStatus>([
|
||||
'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<Record<string, unknown>> {
|
||||
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 (
|
||||
<section className="ota-card" aria-label="OTA safe summary">
|
||||
<h2>OTA safe summary</h2>
|
||||
<p>Read-only OTA metadata: update operations and source-specific details are excluded.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Current version</dt>
|
||||
<dd>{display(snapshot.currentVersion)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{display(snapshot.status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Progress</dt>
|
||||
<dd>
|
||||
{snapshot.progressPercent === undefined
|
||||
? 'Unavailable'
|
||||
: `${snapshot.progressPercent}%`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Update availability</dt>
|
||||
<dd>
|
||||
{snapshot.updateAvailable === undefined
|
||||
? 'Unavailable'
|
||||
: snapshot.updateAvailable
|
||||
? 'Available'
|
||||
: 'No update available'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OtaModule({ instance, dataSource, refreshSignal }: OtaModuleProps) {
|
||||
const requestFence = useRef(0);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [state, setState] = useState<ReadState>({ 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<unknown>;
|
||||
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 (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before OTA data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="OTA unavailable">
|
||||
No safe OTA read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const retained =
|
||||
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
|
||||
? state.snapshot
|
||||
: undefined;
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retained) {
|
||||
return (
|
||||
<p role="status" aria-label="OTA loading status">
|
||||
Loading OTA data…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'error' && !retained) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load OTA data: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading OTA data
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained;
|
||||
if (!snapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="ota-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing OTA data…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known OTA data.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading OTA data
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="OTA freshness">
|
||||
OTA data is {instance.freshness}; verify freshness before relying on these values.
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user