fix(history): respect export permission boundary
This commit is contained in:
@@ -13,11 +13,14 @@ const mocks = vi.hoisted(() => ({
|
||||
historyExports: vi.fn(),
|
||||
createHistoryExport: vi.fn()
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' }));
|
||||
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
auth.role = 'admin';
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
@@ -121,3 +124,18 @@ test('opens a working column visibility panel and preserves non-hideable identit
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' }));
|
||||
expect(screen.queryByRole('dialog', { name: '列显示设置' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps history readable without mounting operator-only export requests for a viewer', async () => {
|
||||
auth.role = 'viewer';
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
renderPage();
|
||||
|
||||
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('只读 · 导出需操作员权限')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /创建导出/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('导出任务')).not.toBeInTheDocument();
|
||||
expect(mocks.historyExports).not.toHaveBeenCalled();
|
||||
expect(mocks.createHistoryExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, History
|
||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canOperate } from '../auth/session';
|
||||
|
||||
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
|
||||
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
|
||||
@@ -80,6 +82,8 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { session } = usePlatformSession();
|
||||
const exportAllowed = canOperate(session);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const today = useMemo(currentHistoryWindow, []);
|
||||
const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' };
|
||||
@@ -161,7 +165,7 @@ export default function HistoryPage() {
|
||||
<label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
|
||||
<label><span>数据类型</span><select value={draft.category} onChange={(event) => setDraft((value) => ({ ...value, category: event.target.value }))}>{(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value="">全部来源</option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
|
||||
<button className="v2-primary-button" type="submit" disabled={!parseHistoryKeywords(draft.keywords).length}>查询</button><button className="v2-secondary-button" type="button" onClick={reset}>重置</button><CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} />
|
||||
<button className="v2-primary-button" type="submit" disabled={!parseHistoryKeywords(draft.keywords).length}>查询</button><button className="v2-secondary-button" type="button" onClick={reset}>重置</button>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <span className="v2-role-badge">只读 · 导出需操作员权限</span>}
|
||||
</form>
|
||||
<div className="v2-history-metrics"><strong>指标字段</strong>{allMetrics.map((metric) => <button type="button" className={visibleKeys.includes(metric.key) ? 'is-active' : ''} onClick={() => toggleMetric(metric.key)} key={metric.key}><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{!allMetrics.length ? <span>查询后加载可用指标</span> : null}</div>
|
||||
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
|
||||
@@ -171,7 +175,7 @@ export default function HistoryPage() {
|
||||
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
|
||||
<section className={`v2-history-table-card is-${density}`}><header><strong>数据明细</strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting />列设置</button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact">紧凑</option><option value="comfortable">舒适</option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th>设备时间</th><th>服务时间</th><th>车牌</th><th>VIN</th><th>协议</th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th>质量</th><th>操作</th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}>查看证据</button></td></tr>)}</tbody></table>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i />正在加载当前筛选范围的历史数据…</div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option><option value="100">100 条/页</option></select></div></footer></section>
|
||||
</div>
|
||||
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRowRef(undefined)} /><ExportJobsPanel /></aside>
|
||||
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user