240 lines
8.6 KiB
TypeScript
240 lines
8.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { cleanup, render, screen, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { InstanceContext } from '../app-shell.js';
|
|
import {
|
|
NotificationsModule,
|
|
type NotificationsDataSource,
|
|
type NotificationsSnapshot,
|
|
} from './notifications-module.js';
|
|
|
|
afterEach(cleanup);
|
|
|
|
const owner: InstanceContext = {
|
|
id: 'alpha',
|
|
name: 'Alpha',
|
|
origin: 'https://alpha.example',
|
|
status: 'online',
|
|
authentication: 'authenticated',
|
|
freshness: 'fresh',
|
|
};
|
|
|
|
const snapshot: NotificationsSnapshot = {
|
|
observedAt: '2026-07-17T12:00:00Z',
|
|
channels: {
|
|
status: 'healthy',
|
|
total: 5,
|
|
enabled: 4,
|
|
disabled: 1,
|
|
healthy: 3,
|
|
degraded: 1,
|
|
failed: 0,
|
|
endpoint: 'https://private.example/hook',
|
|
destination: '+15550000123',
|
|
token: 'channel-secret',
|
|
config: { provider: 'private-provider' },
|
|
},
|
|
queue: {
|
|
status: 'processing',
|
|
total: 18,
|
|
pending: 4,
|
|
processing: 2,
|
|
delivered: 11,
|
|
failed: 1,
|
|
payload: 'private queue payload',
|
|
destination: 'private@example.test',
|
|
},
|
|
logs: {
|
|
status: 'available',
|
|
total: 30,
|
|
info: 20,
|
|
warning: 7,
|
|
error: 3,
|
|
rawLogs: ['private raw log line'],
|
|
phoneNumber: '+15550000456',
|
|
},
|
|
};
|
|
|
|
function deferredSource() {
|
|
let resolve!: (value: unknown) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const load = vi.fn<NotificationsDataSource['load']>(
|
|
(_instanceId, _signal) =>
|
|
new Promise((done, fail) => {
|
|
void _instanceId;
|
|
void _signal;
|
|
resolve = done;
|
|
reject = fail;
|
|
}),
|
|
);
|
|
return {
|
|
source: { load },
|
|
load,
|
|
resolve: (value: unknown) => resolve(value),
|
|
reject: (reason: unknown) => reject(reason),
|
|
};
|
|
}
|
|
|
|
describe('Notifications isolated safe read module', () => {
|
|
it('reads only through the injected exact-owner source and renders allowlisted aggregates', async () => {
|
|
const pending = deferredSource();
|
|
render(<NotificationsModule instance={owner} dataSource={pending.source} />);
|
|
|
|
expect(screen.getByRole('status', { name: '通知加载状态' })).toBeTruthy();
|
|
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
|
pending.resolve(snapshot);
|
|
|
|
const channels = await screen.findByRole('region', { name: '渠道汇总' });
|
|
expect(within(channels).getByText('正常', { selector: 'dd' })).toBeTruthy();
|
|
expect(within(channels).getByText('5')).toBeTruthy();
|
|
expect(within(screen.getByRole('region', { name: '队列汇总' })).getByText('18')).toBeTruthy();
|
|
expect(within(screen.getByRole('region', { name: '日志汇总' })).getByText('30')).toBeTruthy();
|
|
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
|
|
});
|
|
|
|
it('never exposes polymorphic config, endpoints, destinations, tokens, phone numbers, payloads, raw logs, or actions', async () => {
|
|
render(<NotificationsModule instance={owner} dataSource={{ load: async () => snapshot }} />);
|
|
expect(await screen.findByText(/仅显示汇总计数和状态/i)).toBeTruthy();
|
|
|
|
const text = document.body.textContent ?? '';
|
|
for (const secret of [
|
|
'https://private.example/hook',
|
|
'+15550000123',
|
|
'channel-secret',
|
|
'private-provider',
|
|
'private queue payload',
|
|
'private@example.test',
|
|
'private raw log line',
|
|
'+15550000456',
|
|
])
|
|
expect(text).not.toContain(secret);
|
|
expect(
|
|
screen.queryByText(/endpoint|destination|token|phone number|payload|raw log/i),
|
|
).toBeNull();
|
|
expect(screen.queryByRole('button')).toBeNull();
|
|
expect(screen.queryByRole('link')).toBeNull();
|
|
});
|
|
|
|
it('fails closed without an injected source or authenticated owner', async () => {
|
|
const load = vi.fn<NotificationsDataSource['load']>().mockResolvedValue(snapshot);
|
|
const { rerender } = render(<NotificationsModule instance={owner} />);
|
|
expect(screen.getByRole('status', { name: '通知不可用' }).textContent).toMatch(
|
|
/没有可用的安全通知只读数据源.*未签订契约的生产端点/i,
|
|
);
|
|
|
|
rerender(
|
|
<NotificationsModule
|
|
instance={{ ...owner, authentication: 'auth-required' }}
|
|
dataSource={{ load }}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
|
expect(load).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears prior-owner data, aborts replaced reads, and fences late responses', async () => {
|
|
const alpha = deferredSource();
|
|
const bravo = deferredSource();
|
|
const load = vi.fn<NotificationsDataSource['load']>((id, signal) =>
|
|
id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal),
|
|
);
|
|
const source = { load };
|
|
const { rerender } = render(<NotificationsModule instance={owner} dataSource={source} />);
|
|
const alphaSignal = load.mock.calls[0]?.[1];
|
|
|
|
rerender(
|
|
<NotificationsModule
|
|
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
|
dataSource={source}
|
|
/>,
|
|
);
|
|
expect(alphaSignal?.aborted).toBe(true);
|
|
alpha.resolve(snapshot);
|
|
bravo.resolve({
|
|
...snapshot,
|
|
channels: { ...snapshot.channels, status: 'degraded', total: 9 },
|
|
});
|
|
expect(await screen.findByText('功能受限', { selector: 'dd' })).toBeTruthy();
|
|
expect(screen.queryByText('正常', { selector: 'dd' })).toBeNull();
|
|
});
|
|
|
|
it('uses fixed errors and retains only same-owner last-good data across refresh failure and retry', async () => {
|
|
const user = userEvent.setup();
|
|
const load = vi
|
|
.fn<NotificationsDataSource['load']>()
|
|
.mockResolvedValueOnce(snapshot)
|
|
.mockRejectedValueOnce(new Error('secret endpoint and token'))
|
|
.mockResolvedValueOnce({ ...snapshot, channels: { status: 'available', total: 6 } });
|
|
const source = { load };
|
|
const { rerender } = render(
|
|
<NotificationsModule instance={owner} dataSource={source} refreshSignal={0} />,
|
|
);
|
|
expect(await screen.findByText('正常', { selector: 'dd' })).toBeTruthy();
|
|
|
|
rerender(<NotificationsModule instance={owner} dataSource={source} refreshSignal={1} />);
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert.textContent).toMatch(/刷新失败,正在显示上次已知的通知数据/i);
|
|
expect(alert.textContent).not.toContain('secret endpoint');
|
|
expect(screen.getByText('正常', { selector: 'dd' })).toBeTruthy();
|
|
await user.click(screen.getByRole('button', { name: '重试加载通知数据' }));
|
|
expect(
|
|
await within(screen.getByRole('region', { name: '渠道汇总' })).findByText('可用'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('copies a bounded safe snapshot and drops malformed nested data and nominal-field secrets', async () => {
|
|
const unsafe = {
|
|
observedAt: 'api-token@example.test',
|
|
channels: {
|
|
status: 'channel-secret',
|
|
total: -1,
|
|
enabled: 1.5,
|
|
disabled: 2,
|
|
healthy: Number.POSITIVE_INFINITY,
|
|
},
|
|
queue: ['private payload'],
|
|
logs: { status: { token: 'nested-secret' }, total: Number.MAX_SAFE_INTEGER + 1, error: 3 },
|
|
};
|
|
render(<NotificationsModule instance={owner} dataSource={{ load: async () => unsafe }} />);
|
|
const logs = await screen.findByRole('region', { name: '日志汇总' });
|
|
expect(within(logs).getByText('3')).toBeTruthy();
|
|
expect(within(screen.getByRole('region', { name: '渠道汇总' })).getByText('2')).toBeTruthy();
|
|
expect(document.body.textContent).not.toMatch(
|
|
/api-token|channel-secret|private payload|nested-secret|Infinity|1\.5|-1/,
|
|
);
|
|
expect(screen.queryByText(/^观测时间:/)).toBeNull();
|
|
unsafe.channels.disabled = 99;
|
|
expect(screen.queryByText('99')).toBeNull();
|
|
});
|
|
|
|
it('uses a fixed initial-load error without rejection details', async () => {
|
|
render(
|
|
<NotificationsModule
|
|
instance={owner}
|
|
dataSource={{ load: async () => Promise.reject(new Error('raw log and private token')) }}
|
|
/>,
|
|
);
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert.textContent).toContain('无法加载通知数据。');
|
|
expect(alert.textContent).not.toContain('private token');
|
|
});
|
|
|
|
it('turns a synchronous source throw into the fixed initial-load error', async () => {
|
|
render(
|
|
<NotificationsModule
|
|
instance={owner}
|
|
dataSource={{
|
|
load: () => {
|
|
throw new Error('private notification token');
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert.textContent).toContain('无法加载通知数据。');
|
|
expect(alert.textContent).not.toContain('private notification token');
|
|
});
|
|
});
|