feat(web): add messages and calls module slices
This commit is contained in:
@@ -6,6 +6,7 @@ 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 { CallsModule, type CallsDataSource } from './instances/calls-module.js';
|
||||
import { CellularModule, type CellularDataSource } from './instances/cellular-module.js';
|
||||
import {
|
||||
DeviceNetworkModule,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from './instances/device-network-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 {
|
||||
InstanceDetail,
|
||||
INSTANCE_MODULE_LABELS,
|
||||
@@ -70,6 +72,8 @@ export interface AppShellProps {
|
||||
overviewDataSource?: OverviewDataSource;
|
||||
cellularDataSource?: CellularDataSource;
|
||||
deviceNetworkDataSource?: DeviceNetworkDataSource;
|
||||
messagesDataSource?: MessagesDataSource;
|
||||
callsDataSource?: CallsDataSource;
|
||||
eventStreamClient?: EventStreamClient;
|
||||
}
|
||||
|
||||
@@ -140,6 +144,8 @@ function Page({
|
||||
overviewDataSource,
|
||||
cellularDataSource,
|
||||
deviceNetworkDataSource,
|
||||
messagesDataSource,
|
||||
callsDataSource,
|
||||
fleetRefreshSignal,
|
||||
detailRefreshSignal,
|
||||
}: {
|
||||
@@ -153,6 +159,8 @@ function Page({
|
||||
overviewDataSource: OverviewDataSource | undefined;
|
||||
cellularDataSource: CellularDataSource | undefined;
|
||||
deviceNetworkDataSource: DeviceNetworkDataSource | undefined;
|
||||
messagesDataSource: MessagesDataSource | undefined;
|
||||
callsDataSource: CallsDataSource | undefined;
|
||||
fleetRefreshSignal: number;
|
||||
detailRefreshSignal: number;
|
||||
}): ReactNode {
|
||||
@@ -229,6 +237,28 @@ function Page({
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(module === 'messages' && instance
|
||||
? {
|
||||
moduleContent: (
|
||||
<MessagesModule
|
||||
instance={instance}
|
||||
{...(messagesDataSource ? { dataSource: messagesDataSource } : {})}
|
||||
refreshSignal={detailRefreshSignal}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(module === 'calls' && instance
|
||||
? {
|
||||
moduleContent: (
|
||||
<CallsModule
|
||||
instance={instance}
|
||||
{...(callsDataSource ? { dataSource: callsDataSource } : {})}
|
||||
refreshSignal={detailRefreshSignal}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -262,6 +292,8 @@ export function AppShell({
|
||||
overviewDataSource,
|
||||
cellularDataSource,
|
||||
deviceNetworkDataSource,
|
||||
messagesDataSource,
|
||||
callsDataSource,
|
||||
eventStreamClient,
|
||||
}: AppShellProps) {
|
||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||
@@ -317,6 +349,8 @@ export function AppShell({
|
||||
overviewDataSource={overviewDataSource}
|
||||
cellularDataSource={cellularDataSource}
|
||||
deviceNetworkDataSource={deviceNetworkDataSource}
|
||||
messagesDataSource={messagesDataSource}
|
||||
callsDataSource={callsDataSource}
|
||||
fleetRefreshSignal={refresh.fleet}
|
||||
detailRefreshSignal={refresh.detail}
|
||||
/>
|
||||
|
||||
@@ -91,4 +91,17 @@ export {
|
||||
type DeviceNetworkSnapshot,
|
||||
} from './instances/device-network-module.js';
|
||||
|
||||
export {
|
||||
CallsModule,
|
||||
type CallsDataSource,
|
||||
type CallsModuleProps,
|
||||
type CallsSnapshot,
|
||||
} from './instances/calls-module.js';
|
||||
export {
|
||||
MessagesModule,
|
||||
type MessagesDataSource,
|
||||
type MessagesModuleProps,
|
||||
type MessagesSnapshot,
|
||||
} from './instances/messages-module.js';
|
||||
|
||||
export const webWorkspaceReady = true;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @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 { CallsModule, type CallsDataSource, type CallsSnapshot } from './calls-module.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
|
||||
const snapshot: CallsSnapshot = {
|
||||
observedAt: '2026-07-17T12:30:00Z',
|
||||
calls: { state: 'active', active: 2, ringing: 1, held: 0 },
|
||||
devices: { state: 'available', total: 4, online: 3, busy: 2 },
|
||||
};
|
||||
|
||||
function deferredSource() {
|
||||
let resolvePending!: (value: CallsSnapshot) => void;
|
||||
const load = vi.fn<CallsDataSource['load']>(
|
||||
(_instanceId, _signal) =>
|
||||
new Promise((done) => {
|
||||
void _instanceId;
|
||||
void _signal;
|
||||
resolvePending = done;
|
||||
}),
|
||||
);
|
||||
return { source: { load }, load, resolve: (value: CallsSnapshot) => resolvePending(value) };
|
||||
}
|
||||
|
||||
describe('isolated Calls read module', () => {
|
||||
it('loads only through the injected source and renders aggregate call and device status', async () => {
|
||||
const pending = deferredSource();
|
||||
render(<CallsModule instance={owner} dataSource={pending.source} />);
|
||||
expect(screen.getByRole('status', { name: 'Calls loading status' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const calls = await screen.findByRole('region', { name: 'Call status' });
|
||||
const devices = screen.getByRole('region', { name: 'Device status' });
|
||||
expect(within(calls).getByText('active')).toBeTruthy();
|
||||
expect(within(calls).getByText('2')).toBeTruthy();
|
||||
expect(within(devices).getByText('available')).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T12:30:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('strictly allowlists aggregate fields and exposes no phone numbers, audio, logs, or controls', async () => {
|
||||
const unsafe = {
|
||||
...snapshot,
|
||||
phoneNumber: '+1-555-0100',
|
||||
audio: 'private recording body',
|
||||
logBody: 'private call log body',
|
||||
calls: { ...snapshot.calls, caller: '+1-555-0101', transcript: 'private transcript' },
|
||||
devices: { ...snapshot.devices, phoneNumber: '+1-555-0102', audioUrl: 'secret-audio-url' },
|
||||
} as CallsSnapshot;
|
||||
render(<CallsModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
||||
expect(await screen.findByText('available')).toBeTruthy();
|
||||
expect(document.body.textContent).not.toMatch(/555|private|secret-audio/i);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed without authentication or an injected source', async () => {
|
||||
const load = vi.fn<CallsDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(
|
||||
<CallsModule
|
||||
instance={{ ...owner, authentication: 'auth-required' }}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<CallsModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Calls unavailable' }).textContent).toMatch(
|
||||
/no safe calls read data source.*will not invent.*production endpoint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses fixed safe errors, retries, and retains the same owner snapshot on refresh failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<CallsDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('secret endpoint and telephone'))
|
||||
.mockResolvedValueOnce({ ...snapshot, calls: { ...snapshot.calls, active: 3 } });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<CallsModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('available')).toBeTruthy();
|
||||
rerender(<CallsModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toMatch(/showing the last known Calls data/i);
|
||||
expect(alert.textContent).not.toMatch(/secret endpoint|telephone/i);
|
||||
expect(screen.getByText('available')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Calls' }));
|
||||
expect(
|
||||
await within(screen.getByRole('region', { name: 'Call status' })).findByText('3'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('aborts superseded reads and fences late data from another owner', async () => {
|
||||
const alpha = deferredSource();
|
||||
const bravo = deferredSource();
|
||||
const load = vi.fn<CallsDataSource['load']>((id, signal) =>
|
||||
id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<CallsModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
rerender(
|
||||
<CallsModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
|
||||
);
|
||||
expect(alphaSignal?.aborted).toBe(true);
|
||||
alpha.resolve({ ...snapshot, calls: { state: 'late-alpha' } });
|
||||
bravo.resolve({ ...snapshot, calls: { state: 'current-bravo' } });
|
||||
expect(await screen.findByText('current-bravo')).toBeTruthy();
|
||||
expect(screen.queryByText('late-alpha')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
|
||||
/** Bounded values permitted in aggregate call/device status. */
|
||||
export type CallsStatusValue = string | number | boolean | null;
|
||||
|
||||
/** Aggregate call state only. Per-call identity, phone numbers, logs, and media are unsupported. */
|
||||
export interface AggregateCallStatus {
|
||||
readonly state?: CallsStatusValue;
|
||||
readonly total?: CallsStatusValue;
|
||||
readonly active?: CallsStatusValue;
|
||||
readonly ringing?: CallsStatusValue;
|
||||
readonly held?: CallsStatusValue;
|
||||
readonly failed?: CallsStatusValue;
|
||||
}
|
||||
|
||||
/** Aggregate device state only. Device phone numbers and media are intentionally unsupported. */
|
||||
export interface AggregateCallDeviceStatus {
|
||||
readonly state?: CallsStatusValue;
|
||||
readonly total?: CallsStatusValue;
|
||||
readonly online?: CallsStatusValue;
|
||||
readonly offline?: CallsStatusValue;
|
||||
readonly busy?: CallsStatusValue;
|
||||
}
|
||||
|
||||
export interface CallsSnapshot {
|
||||
readonly observedAt?: string;
|
||||
readonly calls: AggregateCallStatus;
|
||||
readonly devices: AggregateCallDeviceStatus;
|
||||
}
|
||||
|
||||
export interface CallsDataSource {
|
||||
/** Supplied by the authenticated owner; this isolated module defines no production endpoint. */
|
||||
load(instanceId: string, signal: AbortSignal): Promise<CallsSnapshot>;
|
||||
}
|
||||
|
||||
export interface CallsModuleProps {
|
||||
readonly instance: InstanceContext;
|
||||
readonly dataSource?: CallsDataSource;
|
||||
/** Change this owner-provided value to request another read. */
|
||||
readonly refreshSignal?: unknown;
|
||||
}
|
||||
|
||||
type OwnedSnapshot = { readonly ownerId: string; readonly value: CallsSnapshot };
|
||||
type ReadState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading'; snapshot?: OwnedSnapshot }
|
||||
| { kind: 'ready'; snapshot: OwnedSnapshot }
|
||||
| { kind: 'error'; snapshot?: OwnedSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Calls data could not be loaded.';
|
||||
|
||||
const CALL_FIELDS = [
|
||||
['State', 'state'],
|
||||
['Total', 'total'],
|
||||
['Active', 'active'],
|
||||
['Ringing', 'ringing'],
|
||||
['Held', 'held'],
|
||||
['Failed', 'failed'],
|
||||
] as const;
|
||||
|
||||
const DEVICE_FIELDS = [
|
||||
['State', 'state'],
|
||||
['Total', 'total'],
|
||||
['Online', 'online'],
|
||||
['Offline', 'offline'],
|
||||
['Busy', 'busy'],
|
||||
] as const;
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="calls-card" aria-label={label}>
|
||||
<h2>{label}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AggregateFields({
|
||||
values,
|
||||
fields,
|
||||
}: {
|
||||
values: object;
|
||||
fields: readonly (readonly [label: string, key: string])[];
|
||||
}) {
|
||||
const safeValues = values as Readonly<Record<string, CallsStatusValue | undefined>>;
|
||||
const supplied = fields.filter(([, key]) => safeValues[key] !== undefined);
|
||||
if (!supplied.length) return <p>No aggregate status was supplied.</p>;
|
||||
return (
|
||||
<dl>
|
||||
{supplied.map(([label, key]) => (
|
||||
<div key={key}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{safeValues[key] == null ? 'Unavailable' : String(safeValues[key])}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) {
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<div className="calls-grid">
|
||||
<Section label="Call status">
|
||||
<AggregateFields values={snapshot.calls} fields={CALL_FIELDS} />
|
||||
</Section>
|
||||
<Section label="Device status">
|
||||
<AggregateFields values={snapshot.devices} fields={DEVICE_FIELDS} />
|
||||
</Section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallsModule({ instance, dataSource, refreshSignal }: CallsModuleProps) {
|
||||
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 snapshot =
|
||||
current.kind !== 'idle' && current.snapshot?.ownerId === ownerId
|
||||
? current.snapshot
|
||||
: undefined;
|
||||
return { kind: 'loading', ...(snapshot ? { snapshot } : {}) };
|
||||
});
|
||||
|
||||
void dataSource.load(ownerId, controller.signal).then(
|
||||
(value) => {
|
||||
if (request === requestFence.current && !controller.signal.aborted)
|
||||
setState({ kind: 'ready', snapshot: { ownerId, value } });
|
||||
},
|
||||
(_reason: unknown) => {
|
||||
void _reason;
|
||||
if (request === requestFence.current && !controller.signal.aborted)
|
||||
setState((current) => {
|
||||
const snapshot =
|
||||
current.kind === 'loading' && current.snapshot?.ownerId === ownerId
|
||||
? current.snapshot
|
||||
: undefined;
|
||||
return { kind: 'error', ...(snapshot ? { 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 Calls data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Calls unavailable">
|
||||
No safe Calls 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="Calls loading status">
|
||||
Loading Calls…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !snapshot)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Calls: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Calls
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!snapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="calls-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Calls…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Calls data.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Calls
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Calls freshness">
|
||||
Calls data is {instance.freshness}; verify freshness before relying on these values.
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={snapshot.value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// @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 {
|
||||
MessagesModule,
|
||||
type MessagesDataSource,
|
||||
type MessagesSnapshot,
|
||||
} from './messages-module.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
|
||||
const snapshot: MessagesSnapshot = {
|
||||
observedAt: '2026-07-17T12:00:00Z',
|
||||
sms: {
|
||||
total: 42,
|
||||
inbound: 25,
|
||||
outbound: 17,
|
||||
unread: 3,
|
||||
failed: 2,
|
||||
queued: 1,
|
||||
lastActivityAt: '2026-07-17T11:55:00Z',
|
||||
// Deliberate excess fields: payloads and recipient data must never reach the DOM.
|
||||
body: 'private message body',
|
||||
content: 'private message content',
|
||||
recipient: '+15550199',
|
||||
recipientCredential: 'secret-recipient-token',
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
deviceId: 'modem-1',
|
||||
label: 'Primary modem',
|
||||
state: 'online',
|
||||
total: 30,
|
||||
inbound: 18,
|
||||
outbound: 12,
|
||||
unread: 2,
|
||||
failed: 1,
|
||||
queued: 0,
|
||||
lastActivityAt: '2026-07-17T11:54:00Z',
|
||||
body: 'device body must not render',
|
||||
phoneNumber: '+15550123',
|
||||
password: 'device credential',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function deferredSource() {
|
||||
let resolve!: (value: MessagesSnapshot) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const load = vi.fn<MessagesDataSource['load']>(
|
||||
(_instanceId, _signal) =>
|
||||
new Promise((done, fail) => {
|
||||
void _instanceId;
|
||||
void _signal;
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
source: { load },
|
||||
load,
|
||||
resolve: (value: MessagesSnapshot) => resolve(value),
|
||||
reject: (reason: unknown) => reject(reason),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Messages isolated read module', () => {
|
||||
it('loads only through the injected exact-owner source and renders aggregate SMS/device metadata', async () => {
|
||||
const pending = deferredSource();
|
||||
render(<MessagesModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Messages loading status' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const sms = await screen.findByRole('region', { name: 'SMS aggregate' });
|
||||
expect(within(sms).getByText('42')).toBeTruthy();
|
||||
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
|
||||
const devices = screen.getByRole('region', { name: 'Device message aggregates' });
|
||||
expect(within(devices).getByText('modem-1')).toBeTruthy();
|
||||
expect(within(devices).getByText('Primary modem')).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => {
|
||||
render(<MessagesModule instance={owner} dataSource={{ load: async () => snapshot }} />);
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
const text = document.body.textContent ?? '';
|
||||
for (const secret of [
|
||||
'private message body',
|
||||
'private message content',
|
||||
'+15550199',
|
||||
'secret-recipient-token',
|
||||
'device body must not render',
|
||||
'+15550123',
|
||||
'device credential',
|
||||
]) {
|
||||
expect(text).not.toContain(secret);
|
||||
}
|
||||
expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.getByText(/aggregate metadata only/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/sending, deleting, and changing messages are unavailable/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not invent an endpoint when no source is injected', () => {
|
||||
render(<MessagesModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: 'Messages unavailable' }).textContent).toMatch(
|
||||
/no safe messages read data source.*uncontracted production endpoint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires the exact owner to remain authenticated and clears prior owner data', async () => {
|
||||
const load = vi.fn<MessagesDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<MessagesModule instance={owner} dataSource={{ load }} />);
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<MessagesModule
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo', authentication: 'auth-required' }}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses a fixed safe error and retries without exposing rejection details', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<MessagesDataSource['load']>()
|
||||
.mockRejectedValueOnce(new Error('secret URL and recipient credential'))
|
||||
.mockResolvedValueOnce(snapshot);
|
||||
render(<MessagesModule instance={owner} dataSource={{ load }} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Messages data could not be loaded.');
|
||||
expect(alert.textContent).not.toContain('secret URL');
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Messages' }));
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retains only the same owner last-good snapshot during refresh and after refresh failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<MessagesDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('unsafe detail'))
|
||||
.mockResolvedValueOnce({ ...snapshot, devices: [{ label: 'Replacement modem', total: 4 }] });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<MessagesModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
|
||||
rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/showing the last known/i);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading Messages' }));
|
||||
expect(await screen.findByText('Replacement modem')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('aborts replaced reads and fences late responses from another owner', async () => {
|
||||
const alpha = deferredSource();
|
||||
const bravo = deferredSource();
|
||||
const load = vi.fn<MessagesDataSource['load']>((instanceId, signal) =>
|
||||
instanceId === 'alpha'
|
||||
? alpha.source.load(instanceId, signal)
|
||||
: bravo.source.load(instanceId, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<MessagesModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
|
||||
rerender(
|
||||
<MessagesModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
|
||||
);
|
||||
expect(alphaSignal?.aborted).toBe(true);
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
alpha.resolve(snapshot);
|
||||
bravo.resolve({ ...snapshot, devices: [{ label: 'Bravo modem', total: 9 }] });
|
||||
expect(await screen.findByText('Bravo modem')).toBeTruthy();
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks owner-declared stale data while preserving aggregates', async () => {
|
||||
render(
|
||||
<MessagesModule
|
||||
instance={{ ...owner, freshness: 'stale' }}
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect((await screen.findByRole('status', { name: 'Messages freshness' })).textContent).toMatch(
|
||||
/stale/i,
|
||||
);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
|
||||
/** Bounded primitives permitted at the Messages presentation boundary. */
|
||||
export type MessageMetadataValue = string | number | boolean | null;
|
||||
|
||||
/** Aggregate SMS metadata only. Message bodies, content, recipients, and credentials are absent. */
|
||||
export interface SmsAggregate {
|
||||
readonly total?: number | null;
|
||||
readonly inbound?: number | null;
|
||||
readonly outbound?: number | null;
|
||||
readonly unread?: number | null;
|
||||
readonly failed?: number | null;
|
||||
readonly queued?: number | null;
|
||||
readonly lastActivityAt?: string | null;
|
||||
}
|
||||
|
||||
/** Non-sensitive device identity/status and aggregate counts only. */
|
||||
export interface DeviceMessageAggregate extends SmsAggregate {
|
||||
readonly deviceId?: string | null;
|
||||
readonly label?: string | null;
|
||||
readonly state?: string | null;
|
||||
}
|
||||
|
||||
export interface MessagesSnapshot {
|
||||
readonly observedAt?: string;
|
||||
readonly sms: SmsAggregate;
|
||||
readonly devices: readonly DeviceMessageAggregate[];
|
||||
}
|
||||
|
||||
export interface MessagesDataSource {
|
||||
/** Supplied by the authenticated owner; this isolated module defines no network endpoint. */
|
||||
load(instanceId: string, signal: AbortSignal): Promise<MessagesSnapshot>;
|
||||
}
|
||||
|
||||
export interface MessagesModuleProps {
|
||||
readonly instance: InstanceContext;
|
||||
readonly dataSource?: MessagesDataSource;
|
||||
/** Change this owner-provided value to request another read. */
|
||||
readonly refreshSignal?: unknown;
|
||||
}
|
||||
|
||||
type ReadState =
|
||||
| { kind: 'idle'; ownerId: string }
|
||||
| { kind: 'loading'; ownerId: string; snapshot?: MessagesSnapshot }
|
||||
| { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot }
|
||||
| { kind: 'error'; ownerId: string; snapshot?: MessagesSnapshot };
|
||||
|
||||
const SAFE_LOAD_ERROR = 'Messages data could not be loaded.';
|
||||
|
||||
const AGGREGATE_FIELDS = [
|
||||
['Total', 'total'],
|
||||
['Inbound', 'inbound'],
|
||||
['Outbound', 'outbound'],
|
||||
['Unread', 'unread'],
|
||||
['Failed', 'failed'],
|
||||
['Queued', 'queued'],
|
||||
['Last activity', 'lastActivityAt'],
|
||||
] as const;
|
||||
|
||||
function display(value: MessageMetadataValue | undefined): string {
|
||||
return value == null ? 'Unavailable' : String(value);
|
||||
}
|
||||
|
||||
function Fields({
|
||||
values,
|
||||
}: {
|
||||
values: readonly (readonly [label: string, value: MessageMetadataValue | undefined])[];
|
||||
}) {
|
||||
return (
|
||||
<dl>
|
||||
{values.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{display(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="messages-card" aria-label={label}>
|
||||
<h2>{label}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateFields(
|
||||
aggregate: SmsAggregate,
|
||||
): readonly (readonly [string, MessageMetadataValue | undefined])[] {
|
||||
// This constant-key projection is the presentation allowlist. Never enumerate source objects.
|
||||
return AGGREGATE_FIELDS.map(([label, key]) => [label, aggregate[key]] as const);
|
||||
}
|
||||
|
||||
function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) {
|
||||
return (
|
||||
<>
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<p>Aggregate metadata only; sensitive payload and addressing details are excluded.</p>
|
||||
<div className="messages-grid">
|
||||
<Section label="SMS aggregate">
|
||||
<Fields values={aggregateFields(snapshot.sms)} />
|
||||
</Section>
|
||||
<Section label="Device message aggregates">
|
||||
{snapshot.devices.length ? (
|
||||
snapshot.devices.map((device, index) => (
|
||||
<article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}>
|
||||
<Fields
|
||||
values={[
|
||||
['Device ID', device.deviceId],
|
||||
['Label', device.label],
|
||||
['State', device.state],
|
||||
...aggregateFields(device),
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>No device message aggregates were supplied.</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
<section className="state-panel" aria-label="Messages actions">
|
||||
<h2>Messages actions</h2>
|
||||
<p>Sending, deleting, and changing messages are unavailable in this read-only module.</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
|
||||
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 }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
void dataSource.load(ownerId, controller.signal).then(
|
||||
(snapshot) => {
|
||||
if (request === requestOwner.current && !controller.signal.aborted) {
|
||||
setState({ kind: 'ready', ownerId, snapshot });
|
||||
}
|
||||
},
|
||||
(_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 Messages data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Messages unavailable">
|
||||
No safe Messages read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const retainedSnapshot =
|
||||
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
|
||||
? state.snapshot
|
||||
: undefined;
|
||||
|
||||
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) {
|
||||
return (
|
||||
<p role="status" aria-label="Messages loading status">
|
||||
Loading Messages…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'error' && !retainedSnapshot) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load Messages: {SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Messages
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentSnapshot =
|
||||
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retainedSnapshot;
|
||||
if (!currentSnapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="messages-module">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing Messages…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known Messages data.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading Messages
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Messages freshness">
|
||||
Messages data is {instance.freshness}; verify freshness before relying on these values.
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={currentSnapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user