feat(sms): add real instance message workflow
This commit is contained in:
@@ -6,6 +6,8 @@ export interface UpstreamRequest {
|
||||
readonly secret?: string;
|
||||
/** Redacted representation suitable for observability, never the password. */
|
||||
readonly body?: '[REDACTED]';
|
||||
/** Explicit one-shot SMS action payload. Implementations must not log or persist it. */
|
||||
readonly sms?: { readonly phoneNumber: string; readonly content: string };
|
||||
}
|
||||
export interface UpstreamResponse {
|
||||
readonly status: number;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { InstanceSessionStore } from '../connections/upstream-session-client.js';
|
||||
import {
|
||||
InstanceMessageService,
|
||||
MessageServiceError,
|
||||
parseMessageList,
|
||||
} from './instance-message-service.js';
|
||||
|
||||
const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' };
|
||||
const instances = { get: vi.fn(async (id: string) => (id === 'alpha' ? instance : undefined)) };
|
||||
|
||||
describe('InstanceMessageService', () => {
|
||||
it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => {
|
||||
const messages = parseMessageList(
|
||||
{
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
status: 'success',
|
||||
data: {
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
direction: 'received',
|
||||
phone_number: '+15550199',
|
||||
content: 'hello',
|
||||
timestamp: '2026-07-19T12:00:00Z',
|
||||
status: 'delivered',
|
||||
pdu: 'SECRET-PDU',
|
||||
transport: 'modem',
|
||||
password: 'secret',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
10,
|
||||
);
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: 'm1',
|
||||
direction: 'received',
|
||||
phoneNumber: '+15550199',
|
||||
content: 'hello',
|
||||
timestamp: '2026-07-19T12:00:00Z',
|
||||
status: 'delivered',
|
||||
transport: 'modem',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(messages)).not.toContain('PDU');
|
||||
expect(JSON.stringify(messages)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('uses exact owner, bounded query and matching optional session cookie', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
sessions.set('alpha', instance.origin, 'simadmin_session=opaque');
|
||||
const request = vi.fn(async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: '{"status":"success","data":{"messages":[]}}',
|
||||
}));
|
||||
const service = new InstanceMessageService({
|
||||
instances: instances as never,
|
||||
sessions,
|
||||
request,
|
||||
});
|
||||
await expect(
|
||||
service.list('alpha', { limit: 20, offset: 2, direction: 'outgoing' }),
|
||||
).resolves.toEqual({ messages: [] });
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
url: 'http://192.168.1.10:8080/api/sms/list?limit=20&offset=2&direction=outgoing',
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json', cookie: 'simadmin_session=opaque' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends once with an explicit payload and returns only safe success', async () => {
|
||||
const request = vi.fn(async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: '{"status":"success","message":"queued","data":{"token":"secret"}}',
|
||||
}));
|
||||
const service = new InstanceMessageService({
|
||||
instances: instances as never,
|
||||
sessions: new InstanceSessionStore(),
|
||||
request,
|
||||
});
|
||||
await expect(
|
||||
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
|
||||
).resolves.toEqual({ sent: true });
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
url: 'http://192.168.1.10:8080/api/sms/send',
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
sms: { phoneNumber: '+15550199', content: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for missing owners, stale sessions and upstream status:error', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
sessions.set('alpha', 'http://192.168.1.99', 'simadmin_session=opaque');
|
||||
const request = vi.fn(async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: '{"status":"error","message":"recipient secret"}',
|
||||
}));
|
||||
const service = new InstanceMessageService({
|
||||
instances: instances as never,
|
||||
sessions,
|
||||
request,
|
||||
});
|
||||
await expect(service.list('alpha', { limit: 10, offset: 0 })).rejects.toBeInstanceOf(
|
||||
MessageServiceError,
|
||||
);
|
||||
await expect(service.list('missing', { limit: 10, offset: 0 })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
sessions.clear('alpha');
|
||||
await expect(
|
||||
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
|
||||
).rejects.toMatchObject({ code: 'UPSTREAM_FAILED' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { InstanceService } from '../instances/instance-service.js';
|
||||
import type {
|
||||
InstanceSessionStore,
|
||||
UpstreamResponse,
|
||||
UpstreamSessionClientOptions,
|
||||
} from '../connections/upstream-session-client.js';
|
||||
|
||||
export type MessageDirection = 'received' | 'sent' | 'incoming' | 'outgoing' | 'unknown';
|
||||
export interface InstanceMessage {
|
||||
readonly id: string;
|
||||
readonly direction: MessageDirection;
|
||||
readonly phoneNumber: string;
|
||||
readonly content: string;
|
||||
readonly timestamp: string;
|
||||
readonly status: string;
|
||||
readonly transport: string;
|
||||
}
|
||||
export interface MessageListQuery {
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
readonly direction?: Exclude<MessageDirection, 'unknown'>;
|
||||
}
|
||||
export interface SendMessageInput {
|
||||
readonly phoneNumber: string;
|
||||
readonly content: string;
|
||||
}
|
||||
export type MessageServiceErrorCode =
|
||||
| 'NOT_FOUND'
|
||||
| 'VALIDATION_FAILED'
|
||||
| 'SESSION_INVALID'
|
||||
| 'UPSTREAM_FAILED';
|
||||
export class MessageServiceError extends Error {
|
||||
constructor(readonly code: MessageServiceErrorCode) {
|
||||
super(code);
|
||||
this.name = 'MessageServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_BYTES = 262_144;
|
||||
const DIRECTIONS = new Set<MessageDirection>(['received', 'sent', 'incoming', 'outgoing']);
|
||||
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
const bounded = (value: unknown, maximum: number): string | undefined =>
|
||||
(typeof value === 'string' || typeof value === 'number') &&
|
||||
String(value).length <= maximum &&
|
||||
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value))
|
||||
? String(value)
|
||||
: undefined;
|
||||
|
||||
export function validPhoneNumber(value: string): boolean {
|
||||
return value.length >= 3 && value.length <= 32 && /^\+?[0-9][0-9 ()-]*$/u.test(value);
|
||||
}
|
||||
export function validMessageContent(value: string): boolean {
|
||||
return (
|
||||
value.length >= 1 &&
|
||||
value.length <= 1600 &&
|
||||
Buffer.byteLength(value, 'utf8') <= 6400 &&
|
||||
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMessageList(
|
||||
response: UpstreamResponse,
|
||||
limit: number,
|
||||
): readonly InstanceMessage[] {
|
||||
if (
|
||||
response.status < 200 ||
|
||||
response.status >= 300 ||
|
||||
Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES
|
||||
)
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
let root: Record<string, unknown> | undefined;
|
||||
try {
|
||||
root = record(JSON.parse(response.body));
|
||||
} catch {
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
}
|
||||
if (root?.status !== 'success' && root?.status !== 'ok')
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
const source = record(root.data)?.messages;
|
||||
if (!Array.isArray(source) || source.length > limit)
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
const output: InstanceMessage[] = [];
|
||||
for (const item of source) {
|
||||
const value = record(item);
|
||||
const id = bounded(value?.id, 128);
|
||||
const phoneNumber = bounded(value?.phone_number, 32);
|
||||
const content = bounded(value?.content, 1600);
|
||||
const timestamp = bounded(value?.timestamp, 64);
|
||||
const status = bounded(value?.status, 32);
|
||||
const transport = bounded(value?.transport, 32);
|
||||
const rawDirection = bounded(value?.direction, 16);
|
||||
if (
|
||||
!id ||
|
||||
!phoneNumber ||
|
||||
!validPhoneNumber(phoneNumber) ||
|
||||
content === undefined ||
|
||||
timestamp === undefined ||
|
||||
status === undefined ||
|
||||
transport === undefined
|
||||
)
|
||||
continue;
|
||||
const direction: MessageDirection =
|
||||
rawDirection && DIRECTIONS.has(rawDirection as MessageDirection)
|
||||
? (rawDirection as MessageDirection)
|
||||
: 'unknown';
|
||||
output.push({ id, direction, phoneNumber, content, timestamp, status, transport });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseSendSuccess(response: UpstreamResponse): void {
|
||||
if (
|
||||
response.status < 200 ||
|
||||
response.status >= 300 ||
|
||||
Buffer.byteLength(response.body, 'utf8') > 32_768
|
||||
)
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
try {
|
||||
if (record(JSON.parse(response.body))?.status !== 'success')
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
} catch (error) {
|
||||
if (error instanceof MessageServiceError) throw error;
|
||||
throw new MessageServiceError('UPSTREAM_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceMessageService {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly instances: InstanceService;
|
||||
readonly sessions: InstanceSessionStore;
|
||||
readonly request: UpstreamSessionClientOptions['request'];
|
||||
},
|
||||
) {}
|
||||
|
||||
private async owner(instanceId: string) {
|
||||
const instance = await this.options.instances.get(instanceId);
|
||||
if (!instance) throw new MessageServiceError('NOT_FOUND');
|
||||
const session = this.options.sessions.sessionFor(instanceId);
|
||||
if (session && session.origin !== instance.origin)
|
||||
throw new MessageServiceError('SESSION_INVALID');
|
||||
return { instance, session };
|
||||
}
|
||||
|
||||
async list(
|
||||
instanceId: string,
|
||||
query: MessageListQuery,
|
||||
): Promise<{ readonly messages: readonly InstanceMessage[] }> {
|
||||
if (
|
||||
!Number.isSafeInteger(query.limit) ||
|
||||
query.limit < 1 ||
|
||||
query.limit > 100 ||
|
||||
!Number.isSafeInteger(query.offset) ||
|
||||
query.offset < 0 ||
|
||||
query.offset > 10_000 ||
|
||||
(query.direction !== undefined &&
|
||||
query.direction !== 'incoming' &&
|
||||
query.direction !== 'outgoing')
|
||||
)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const { instance, session } = await this.owner(instanceId);
|
||||
const direction = query.direction ? `&direction=${query.direction}` : '';
|
||||
const response = await this.options.request({
|
||||
url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`,
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json', ...(session ? { cookie: session.cookie } : {}) },
|
||||
});
|
||||
return { messages: parseMessageList(response, query.limit) };
|
||||
}
|
||||
|
||||
async send(instanceId: string, input: SendMessageInput): Promise<{ readonly sent: true }> {
|
||||
if (!validPhoneNumber(input.phoneNumber) || !validMessageContent(input.content))
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const { instance, session } = await this.owner(instanceId);
|
||||
const response = await this.options.request({
|
||||
url: `${instance.origin}/api/sms/send`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(session ? { cookie: session.cookie } : {}),
|
||||
},
|
||||
sms: { phoneNumber: input.phoneNumber, content: input.content },
|
||||
});
|
||||
parseSendSuccess(response);
|
||||
return { sent: true };
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { registerJobRoutes } from './interface/http/job-routes.js';
|
||||
import { AuditQueryService } from './application/audit/audit-query-service.js';
|
||||
import { registerAuditRoutes } from './interface/http/audit-routes.js';
|
||||
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
|
||||
import { InstanceMessageService } from './application/messages/instance-message-service.js';
|
||||
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
@@ -67,6 +68,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
sessions,
|
||||
request: options.upstream.request,
|
||||
});
|
||||
const messages = new InstanceMessageService({
|
||||
instances,
|
||||
sessions,
|
||||
request: options.upstream.request,
|
||||
});
|
||||
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
|
||||
const login = options.now
|
||||
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
|
||||
@@ -96,6 +102,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
login,
|
||||
deletion,
|
||||
resources,
|
||||
messages,
|
||||
registerDeletionPreparationRoute: false,
|
||||
});
|
||||
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
||||
|
||||
@@ -59,7 +59,6 @@ const origin = (raw: string): URL => {
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
)
|
||||
throw new TransportError('UNSAFE_ORIGIN');
|
||||
|
||||
@@ -98,6 +98,53 @@ describe('SafeUpstreamGateway', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => {
|
||||
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
|
||||
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
|
||||
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
|
||||
await gateway.request({
|
||||
url: 'http://192.168.1.20:8080/api/sms/list?limit=20&offset=0&direction=outgoing',
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
await gateway.request({
|
||||
url: 'http://192.168.1.20:8080/api/sms/send',
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
sms: { phoneNumber: '+15550199', content: 'hello' },
|
||||
});
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'http://192.168.1.20:8080/api/sms/send',
|
||||
{ accept: 'application/json', 'content-type': 'application/json' },
|
||||
'{"phone_number":"+15550199","content":"hello"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => {
|
||||
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
|
||||
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
|
||||
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
|
||||
for (const url of [
|
||||
'http://192.168.1.20/api/sms/list?limit=101&offset=0',
|
||||
'http://192.168.1.20/api/sms/list?limit=10&offset=0&pdu=true',
|
||||
'http://192.168.1.20/api/sms/list?offset=0&limit=10',
|
||||
])
|
||||
await expect(gateway.request({ url, method: 'GET', headers: {} })).rejects.toThrow(
|
||||
'UPSTREAM_REQUEST_INVALID',
|
||||
);
|
||||
await expect(
|
||||
gateway.request({
|
||||
url: 'http://192.168.1.20/api/sms/send',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
sms: { phoneNumber: 'bad\nnumber', content: 'hello' },
|
||||
}),
|
||||
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not allow a supplied redacted body marker to become a network request body', async () => {
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: {
|
||||
|
||||
@@ -46,12 +46,21 @@ export class SafeUpstreamGateway {
|
||||
}
|
||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||
const url = new URL(request.url);
|
||||
const headerKeys = Object.keys(request.headers).sort().join(',');
|
||||
if (request.method === 'GET') {
|
||||
const smsList = url.pathname === '/api/sms/list';
|
||||
const smsQuery = smsList
|
||||
? /^(?:limit=([1-9]\d?)|limit=100)&offset=(0|[1-9]\d{0,4})(?:&direction=(incoming|outgoing))?$/u.exec(
|
||||
url.search.slice(1),
|
||||
)
|
||||
: null;
|
||||
if (
|
||||
request.secret !== undefined ||
|
||||
request.body !== undefined ||
|
||||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim') ||
|
||||
url.search ||
|
||||
request.sms !== undefined ||
|
||||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
|
||||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
|
||||
(!smsList && url.search) ||
|
||||
url.hash ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
@@ -62,6 +71,31 @@ export class SafeUpstreamGateway {
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
return this.options.transport.get(request.url, request.headers);
|
||||
}
|
||||
if (url.pathname === '/api/sms/send') {
|
||||
const sms = request.sms;
|
||||
if (
|
||||
request.method !== 'POST' ||
|
||||
request.secret !== undefined ||
|
||||
request.body !== undefined ||
|
||||
!sms ||
|
||||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
|
||||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
|
||||
sms.content.length < 1 ||
|
||||
sms.content.length > 1600 ||
|
||||
Buffer.byteLength(sms.content, 'utf8') > 6400 ||
|
||||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
return this.options.transport.post(
|
||||
request.url,
|
||||
request.headers,
|
||||
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
|
||||
);
|
||||
}
|
||||
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
||||
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
if (request.url.endsWith('/api/auth/login')) {
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
DeleteInstanceOperationError,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
|
||||
import {
|
||||
MessageServiceError,
|
||||
type InstanceMessageService,
|
||||
} from '../../application/messages/instance-message-service.js';
|
||||
|
||||
export interface InstanceRoutesOptions {
|
||||
readonly instances: InstanceService;
|
||||
@@ -27,6 +31,7 @@ export interface InstanceRoutesOptions {
|
||||
readonly login?: InstanceLoginService;
|
||||
readonly deletion?: DeleteInstanceOperation;
|
||||
readonly resources?: InstanceResourceService;
|
||||
readonly messages?: InstanceMessageService;
|
||||
readonly registerDeletionPreparationRoute?: boolean;
|
||||
}
|
||||
const problem = (
|
||||
@@ -303,6 +308,21 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
'The requested session operation could not be completed.',
|
||||
),
|
||||
);
|
||||
if (error instanceof MessageServiceError) {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send(
|
||||
problem(
|
||||
request,
|
||||
status,
|
||||
error.code,
|
||||
'The requested message operation could not be completed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -345,6 +365,55 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
options.resources!.get((request.params as { instanceId: string }).instanceId),
|
||||
),
|
||||
);
|
||||
if (options.messages) {
|
||||
app.get(
|
||||
'/api/v1/instances/:instanceId/messages',
|
||||
wrap(async (request) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
if (Object.keys(query).some((key) => !['limit', 'offset', 'direction'].includes(key)))
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const integer = (key: 'limit' | 'offset', fallback: number): number => {
|
||||
const raw = query[key];
|
||||
if (raw === undefined) return fallback;
|
||||
if (typeof raw !== 'string' || !/^\d+$/u.test(raw))
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return Number(raw);
|
||||
};
|
||||
const direction = query.direction;
|
||||
if (direction !== undefined && direction !== 'incoming' && direction !== 'outgoing')
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return options.messages!.list((request.params as { instanceId: string }).instanceId, {
|
||||
limit: integer('limit', 50),
|
||||
offset: integer('offset', 0),
|
||||
...(direction ? { direction } : {}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/messages/send',
|
||||
{
|
||||
bodyLimit: 8_192,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['phoneNumber', 'content'],
|
||||
properties: {
|
||||
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
|
||||
content: { type: 'string', minLength: 1, maxLength: 1600 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wrap(async (request) => {
|
||||
const value = record(request.body);
|
||||
return options.messages!.send((request.params as { instanceId: string }).instanceId, {
|
||||
phoneNumber: typeof value.phoneNumber === 'string' ? value.phoneNumber : '',
|
||||
content: typeof value.content === 'string' ? value.content : '',
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
app.patch(
|
||||
'/api/v1/instances/:instanceId',
|
||||
{ schema: { body: instancePatchSchema } },
|
||||
|
||||
@@ -129,8 +129,12 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
'/instances/new',
|
||||
);
|
||||
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
|
||||
expect(within(card).getByText('40 ms')).toBeTruthy();
|
||||
expect(within(card).getByText('2.0')).toBeTruthy();
|
||||
expect(within(card).queryByText('延迟')).toBeNull();
|
||||
expect(within(card).queryByText('版本')).toBeNull();
|
||||
expect(within(card).queryByText('新鲜度')).toBeNull();
|
||||
expect(within(card).queryByText('40 ms')).toBeNull();
|
||||
expect(within(card).queryByText('2.0')).toBeNull();
|
||||
expect(within(card).queryByText('可能过期')).toBeNull();
|
||||
expect(within(card).getByText('18.4%')).toBeTruthy();
|
||||
expect(within(card).getByText('63.2%')).toBeTruthy();
|
||||
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
||||
@@ -150,6 +154,34 @@ describe('React AppShell and Fleet vertical slice', () => {
|
||||
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
|
||||
});
|
||||
|
||||
it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => {
|
||||
const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
||||
const { rerender } = render(
|
||||
<AppShell
|
||||
pathname="/jobs"
|
||||
jobsDataSource={jobsDataSource}
|
||||
eventStreamClient={quietEventStreamClient}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy();
|
||||
const navigation = screen.getByRole('navigation', { name: '全局导航' });
|
||||
expect(within(navigation).queryByRole('link', { name: '任务' })).toBeNull();
|
||||
expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull();
|
||||
expect(within(navigation).getByRole('link', { name: '实例总览' })).toBeTruthy();
|
||||
expect(within(navigation).getByRole('link', { name: '设置' })).toBeTruthy();
|
||||
|
||||
const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) };
|
||||
rerender(
|
||||
<AppShell
|
||||
pathname="/audit"
|
||||
auditDataSource={auditDataSource}
|
||||
eventStreamClient={quietEventStreamClient}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads route-owned instance context so overview-card navigation opens detail management', async () => {
|
||||
const instanceDataSource: InstanceDataSource = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createInstanceApiDataSource } from './instances/instance-api-data-sourc
|
||||
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
|
||||
import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
|
||||
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
|
||||
import { createMessagesApiDataSource } from './instances/messages-api-data-source.js';
|
||||
import {
|
||||
NotificationsModule,
|
||||
type NotificationsDataSource,
|
||||
@@ -410,6 +411,7 @@ export function AppShell({
|
||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
||||
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
|
||||
const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []);
|
||||
const resolvedJobsDataSource = useMemo(
|
||||
() => jobsDataSource ?? createJobsApiDataSource(),
|
||||
[jobsDataSource],
|
||||
@@ -488,8 +490,6 @@ export function AppShell({
|
||||
{(
|
||||
[
|
||||
['fleet', '/fleet', '实例总览'],
|
||||
['jobs', '/jobs', '任务'],
|
||||
['audit', '/audit', '审计'],
|
||||
['settings', '/settings/instances', '设置'],
|
||||
] as const
|
||||
).map(([key, href, label]) => (
|
||||
@@ -520,7 +520,7 @@ export function AppShell({
|
||||
overviewDataSource={overviewDataSource}
|
||||
cellularDataSource={cellularDataSource}
|
||||
deviceNetworkDataSource={deviceNetworkDataSource}
|
||||
messagesDataSource={messagesDataSource}
|
||||
messagesDataSource={messagesDataSource ?? defaultMessagesDataSource}
|
||||
callsDataSource={callsDataSource}
|
||||
esimDataSource={esimDataSource}
|
||||
notificationsDataSource={notificationsDataSource}
|
||||
|
||||
@@ -444,18 +444,6 @@ export function FleetPage({
|
||||
<dt>最高温度</dt>
|
||||
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>延迟</dt>
|
||||
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>版本</dt>
|
||||
<dd>{row.version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>新鲜度</dt>
|
||||
<dd>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="capability-tags" aria-label="能力">
|
||||
{row.capabilities.length > 0 ? (
|
||||
|
||||
@@ -28,7 +28,7 @@ const capabilities: InstanceCapabilityMap = {
|
||||
};
|
||||
|
||||
describe('InstanceDetail', () => {
|
||||
it('makes only supported modules actionable and explains every other capability state', () => {
|
||||
it('keeps core overview and message modules actionable while explaining other capability states', () => {
|
||||
render(
|
||||
<InstanceDetail
|
||||
instanceId="owner"
|
||||
@@ -41,8 +41,10 @@ describe('InstanceDetail', () => {
|
||||
expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe(
|
||||
'/instances/owner/overview',
|
||||
);
|
||||
expect(screen.queryByRole('link', { name: '消息' })).toBeNull();
|
||||
expect(screen.getByText('消息历史记录为只读。')).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: '消息' }).getAttribute('href')).toBe(
|
||||
'/instances/owner/messages',
|
||||
);
|
||||
expect(screen.queryByText('消息历史记录为只读。')).toBeNull();
|
||||
expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy();
|
||||
expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy();
|
||||
expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0);
|
||||
|
||||
@@ -55,6 +55,11 @@ function canRender(capability: InstanceCapability): boolean {
|
||||
return capability.state === 'supported' || capability.state === 'degraded';
|
||||
}
|
||||
|
||||
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
|
||||
function canOpen(module: InstanceModule, capability: InstanceCapability): boolean {
|
||||
return CORE_MODULES.has(module) || canRender(capability);
|
||||
}
|
||||
|
||||
function explanation(capability: InstanceCapability): string | null {
|
||||
if (capability.state === 'supported') return null;
|
||||
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
||||
@@ -131,14 +136,6 @@ export function InstanceDetail({
|
||||
<dt>状态</dt>
|
||||
<dd>{displayStatus(instance.status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>认证</dt>
|
||||
<dd>{displayStatus(instance.authentication)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据新鲜度</dt>
|
||||
<dd>{displayStatus(instance.freshness)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{origin ? (
|
||||
<a href={origin} target="_blank" rel="noopener noreferrer">
|
||||
@@ -153,7 +150,7 @@ export function InstanceDetail({
|
||||
const reason = explanation(capability);
|
||||
return (
|
||||
<li key={item} data-capability-state={capability.state}>
|
||||
{capability.state === 'supported' ? (
|
||||
{canOpen(item, capability) ? (
|
||||
<a
|
||||
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
||||
aria-current={module === item ? 'page' : undefined}
|
||||
@@ -176,7 +173,7 @@ export function InstanceDetail({
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
{loading ? <p role="status">正在加载能力…</p> : null}
|
||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||
{!loading && canRender(activeCapability) ? (
|
||||
{!loading && canOpen(module, activeCapability) ? (
|
||||
<>
|
||||
{activeCapability.state === 'degraded' ? (
|
||||
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
||||
@@ -184,7 +181,7 @@ export function InstanceDetail({
|
||||
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
|
||||
</>
|
||||
) : null}
|
||||
{!loading && !canRender(activeCapability) ? (
|
||||
{!loading && !canOpen(module, activeCapability) ? (
|
||||
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { MessagesDataSource, MessagesSnapshot, SendMessageInput } from './messages-module.js';
|
||||
|
||||
interface Options {
|
||||
readonly fetcher?: typeof fetch;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function createMessagesApiDataSource(options: Options = {}): MessagesDataSource {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
return {
|
||||
async load(instanceId, signal): Promise<MessagesSnapshot> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=50&offset=0`,
|
||||
{ headers: { accept: 'application/json' }, signal },
|
||||
);
|
||||
if (!response.ok) throw new Error('message load failed');
|
||||
const root = record(await response.json());
|
||||
if (!Array.isArray(root?.messages)) throw new Error('message load failed');
|
||||
return {
|
||||
messages: root.messages.flatMap((item) => {
|
||||
const value = record(item);
|
||||
return typeof value?.id === 'string' &&
|
||||
typeof value.phoneNumber === 'string' &&
|
||||
typeof value.content === 'string' &&
|
||||
typeof value.timestamp === 'string' &&
|
||||
typeof value.status === 'string' &&
|
||||
typeof value.direction === 'string'
|
||||
? [
|
||||
{
|
||||
id: value.id,
|
||||
phoneNumber: value.phoneNumber,
|
||||
content: value.content,
|
||||
timestamp: value.timestamp,
|
||||
status: value.status,
|
||||
direction: value.direction,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
};
|
||||
},
|
||||
async send(instanceId: string, input: SendMessageInput): Promise<void> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages/send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error('message send failed');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,215 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { cleanup, render, screen } 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';
|
||||
import { MessagesModule, type MessagesDataSource } from './messages-module.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
const instance: InstanceContext = {
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
origin: 'http://192.168.1.2',
|
||||
status: 'unknown',
|
||||
authentication: 'unknown',
|
||||
freshness: 'unknown',
|
||||
};
|
||||
afterEach(cleanup);
|
||||
|
||||
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;
|
||||
describe('MessagesModule', () => {
|
||||
it('shows the real message list including content and never exposes PDU', async () => {
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: '32',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: '余额提醒',
|
||||
timestamp: '2026-07-18 09:09:20',
|
||||
status: 'received',
|
||||
pdu: 'secret-pdu',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
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: '消息加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const sms = await screen.findByRole('region', { name: '短信汇总' });
|
||||
expect(within(sms).getByText('42')).toBeTruthy();
|
||||
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
|
||||
const devices = screen.getByRole('region', { name: '设备消息汇总' });
|
||||
expect(within(devices).getByText('modem-1')).toBeTruthy();
|
||||
expect(within(devices).getByText('Primary modem')).toBeTruthy();
|
||||
expect(within(devices).getByText('在线')).toBeTruthy();
|
||||
expect(within(devices).queryByText('online')).toBeNull();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
send: vi.fn(),
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
expect(await screen.findByText('余额提醒')).toBeTruthy();
|
||||
expect(screen.getByText(/收到 · 10086/)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain('secret-pdu');
|
||||
});
|
||||
|
||||
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(/仅显示汇总元数据/i)).toBeTruthy();
|
||||
expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not invent an endpoint when no source is injected', () => {
|
||||
render(<MessagesModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch(
|
||||
/没有可用的安全消息只读数据源.*未签订契约的生产端点/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(/需要先完成认证/i);
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses a fixed safe error and retries without exposing rejection details', async () => {
|
||||
it('provides an intentional send form and submits exactly once', 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('无法加载消息数据。');
|
||||
expect(alert.textContent).not.toContain('secret URL');
|
||||
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
|
||||
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(/正在显示上次已知/i);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
|
||||
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: '消息数据新鲜度' })).textContent).toMatch(
|
||||
/可能已过期/i,
|
||||
);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
send,
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
|
||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
|
||||
expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,251 +1,159 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import { displayValue } from '../ui/locale.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;
|
||||
export interface SmsMessage {
|
||||
readonly id: string;
|
||||
readonly direction: string;
|
||||
readonly phoneNumber: string;
|
||||
readonly content: string;
|
||||
readonly timestamp: string;
|
||||
readonly status: string;
|
||||
}
|
||||
|
||||
/** 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[];
|
||||
readonly messages: readonly SmsMessage[];
|
||||
}
|
||||
export interface SendMessageInput {
|
||||
readonly phoneNumber: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface MessagesDataSource {
|
||||
/** Supplied by the authenticated owner; this isolated module defines no network endpoint. */
|
||||
load(instanceId: string, signal: AbortSignal): Promise<MessagesSnapshot>;
|
||||
send(instanceId: string, input: SendMessageInput): Promise<void>;
|
||||
}
|
||||
|
||||
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 = '无法加载消息数据。';
|
||||
|
||||
const AGGREGATE_FIELDS = [
|
||||
['总计', 'total'],
|
||||
['接收', 'inbound'],
|
||||
['发送', 'outbound'],
|
||||
['未读', 'unread'],
|
||||
['失败', 'failed'],
|
||||
['排队中', 'queued'],
|
||||
['最近活动', 'lastActivityAt'],
|
||||
] as const;
|
||||
|
||||
function displayRaw(value: MessageMetadataValue | undefined): string {
|
||||
return value == null ? '不可用' : String(value);
|
||||
}
|
||||
|
||||
function Fields({
|
||||
values,
|
||||
}: {
|
||||
values: readonly (readonly [
|
||||
label: string,
|
||||
value: MessageMetadataValue | undefined,
|
||||
localized?: boolean,
|
||||
])[];
|
||||
}) {
|
||||
return (
|
||||
<dl>
|
||||
{values.map(([label, value, localized]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{localized ? displayValue(value) : displayRaw(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>观测时间:{snapshot.observedAt}</p> : null}
|
||||
<p>仅显示汇总元数据;敏感内容和寻址详情已排除。</p>
|
||||
<div className="messages-grid">
|
||||
<Section label="短信汇总">
|
||||
<Fields values={aggregateFields(snapshot.sms)} />
|
||||
</Section>
|
||||
<Section label="设备消息汇总">
|
||||
{snapshot.devices.length ? (
|
||||
snapshot.devices.map((device, index) => (
|
||||
<article key={`${device.deviceId ?? device.label ?? 'device'}-${index}`}>
|
||||
<Fields
|
||||
values={[
|
||||
['设备 ID', device.deviceId],
|
||||
['标签', device.label],
|
||||
['状态', device.state, true],
|
||||
...aggregateFields(device),
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>未提供设备消息汇总。</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
<section className="state-panel" aria-label="消息操作">
|
||||
<h2>消息操作</h2>
|
||||
<p>此只读模块不支持发送、删除或修改消息。</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const directionLabel = (value: string): string =>
|
||||
value === 'incoming' || value === 'received'
|
||||
? '收到'
|
||||
: value === 'outgoing' || value === 'sent'
|
||||
? '发送'
|
||||
: '未知';
|
||||
|
||||
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
|
||||
const requestOwner = useRef(0);
|
||||
const owner = useRef(0);
|
||||
const [messages, setMessages] = useState<readonly SmsMessage[]>();
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [state, setState] = useState<ReadState>({ kind: 'idle', ownerId: instance.id });
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendState, setSendState] = useState<'success' | 'error'>();
|
||||
|
||||
useEffect(() => {
|
||||
const request = ++requestOwner.current;
|
||||
const ownerId = instance.id;
|
||||
const request = ++owner.current;
|
||||
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(
|
||||
setMessages(undefined);
|
||||
setLoadError(false);
|
||||
if (!dataSource) return () => controller.abort();
|
||||
void dataSource.load(instance.id, controller.signal).then(
|
||||
(snapshot) => {
|
||||
if (request === requestOwner.current && !controller.signal.aborted) {
|
||||
setState({ kind: 'ready', ownerId, snapshot });
|
||||
}
|
||||
if (request === owner.current && !controller.signal.aborted) setMessages(snapshot.messages);
|
||||
},
|
||||
(_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 }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
() => {
|
||||
if (request === owner.current && !controller.signal.aborted) setLoadError(true);
|
||||
},
|
||||
);
|
||||
|
||||
return () => controller.abort();
|
||||
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
|
||||
}, [dataSource, instance.id, refreshSignal, retry]);
|
||||
|
||||
if (instance.authentication !== 'authenticated') {
|
||||
async function send(): Promise<void> {
|
||||
if (!dataSource || sending) return;
|
||||
setSending(true);
|
||||
setSendState(undefined);
|
||||
try {
|
||||
await dataSource.send(instance.id, { phoneNumber, content });
|
||||
setContent('');
|
||||
setSendState('success');
|
||||
setRetry((value) => value + 1);
|
||||
} catch {
|
||||
setSendState('error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
需要先完成认证,才能读取消息数据。
|
||||
<div className="state-panel" role="status">
|
||||
短信功能暂不可用。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="消息不可用">
|
||||
没有可用的安全消息只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
||||
</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="消息加载状态">
|
||||
正在加载消息…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'error' && !retainedSnapshot) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>{SAFE_LOAD_ERROR}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
重试加载消息
|
||||
</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">正在刷新消息…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>刷新失败,正在显示上次已知的消息数据。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
重试加载消息
|
||||
<section className="messages-card" aria-labelledby="send-sms-title">
|
||||
<h2 id="send-sms-title">发送短信</h2>
|
||||
<p>发送会立即提交到当前设备,不会自动重试。请确认号码和内容。</p>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
手机号
|
||||
<input
|
||||
name="phoneNumber"
|
||||
value={phoneNumber}
|
||||
onChange={(event) => setPhoneNumber(event.target.value)}
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
短信内容
|
||||
<textarea
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
maxLength={1600}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={sending || !phoneNumber.trim() || !content.trim()}>
|
||||
{sending ? '正在发送…' : '发送短信'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="消息数据新鲜度">
|
||||
消息数据可能已过期;使用这些值前请核实数据新鲜度。
|
||||
</p>
|
||||
) : null}
|
||||
<SnapshotView snapshot={currentSnapshot} />
|
||||
</form>
|
||||
{sendState === 'success' ? <p role="status">短信已提交发送。</p> : null}
|
||||
{sendState === 'error' ? <p role="alert">短信发送失败,请核对设备状态后重试。</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="messages-card" aria-labelledby="sms-list-title">
|
||||
<h2 id="sms-list-title">短信列表</h2>
|
||||
{loadError ? (
|
||||
<div role="alert">
|
||||
<p>无法加载短信列表。</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
) : messages === undefined ? (
|
||||
<p role="status">正在加载短信…</p>
|
||||
) : messages.length === 0 ? (
|
||||
<p>暂无短信。</p>
|
||||
) : (
|
||||
<ol className="sms-list">
|
||||
{messages.map((message) => (
|
||||
<li key={message.id} className="sms-message-card">
|
||||
<header>
|
||||
<strong>
|
||||
{directionLabel(message.direction)} · {message.phoneNumber}
|
||||
</strong>
|
||||
<time>{message.timestamp}</time>
|
||||
</header>
|
||||
<p>{message.content}</p>
|
||||
<small>状态:{message.status}</small>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-3
@@ -586,6 +586,30 @@ dd {
|
||||
.instance-module-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.messages-module,
|
||||
.messages-card,
|
||||
.sms-list,
|
||||
.sms-message-card {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.messages-card form,
|
||||
.messages-card label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.messages-card input,
|
||||
.messages-card textarea {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.sms-list {
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.overview-grid,
|
||||
.cellular-grid,
|
||||
.device-network-grid,
|
||||
@@ -681,11 +705,16 @@ main ul[aria-label='已配置实例'] {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.instance-context ul {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
.instance-context li {
|
||||
min-width: max-content;
|
||||
min-width: 0;
|
||||
}
|
||||
.instance-context nav a,
|
||||
.instance-context nav span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fleet-toolbar label,
|
||||
.jobs-toolbar label {
|
||||
|
||||
Reference in New Issue
Block a user