From 7346cd0891a45c9005c0192ab795d4943ce96afd Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 08:08:06 +0800 Subject: [PATCH] fix(access): respect read-only permission boundary --- .../apps/web/src/v2/pages/AccessPage.test.tsx | 21 ++++++++++++++++++- .../apps/web/src/v2/pages/AccessPage.tsx | 8 +++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index d5711957..1fd5c52b 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -10,11 +10,13 @@ const mocks = vi.hoisted(() => ({ accessSummary: vi.fn(), accessVehicles: vi.fn(), accessUnresolvedIdentities: vi.fn(), accessThresholds: vi.fn(), updateAccessThresholds: vi.fn() })); +const auth = vi.hoisted(() => ({ role: 'admin' })); vi.mock('../../api/client', () => ({ api: mocks })); -vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) })); +vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) })); afterEach(() => { cleanup(); + auth.role = 'admin'; Object.values(mocks).forEach((mock) => mock.mockReset()); }); @@ -76,3 +78,20 @@ test('reports and independently retries unresolved identity and threshold failur expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2); expect(mocks.accessThresholds).toHaveBeenCalledTimes(2); }); + +test('does not request or render admin-only thresholds for a read-only session', async () => { + auth.role = 'viewer'; + prepareBaseData(); + mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect(await screen.findByText('粤A00001')).toBeInTheDocument(); + expect(mocks.accessThresholds).not.toHaveBeenCalled(); + expect(screen.queryByText(/在线判定阈值/)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /刷新/ })); + await waitFor(() => expect(mocks.accessSummary).toHaveBeenCalledTimes(2)); + expect(mocks.accessThresholds).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index 769189eb..b928afa4 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -104,7 +104,7 @@ export default function AccessPage() { const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime }); const vehiclesQuery = useQuery>({ queryKey: ['access-vehicles', vehicleScope, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: retainPreviousPageWithinScope>(vehicleScope), gcTime: QUERY_MEMORY.highVolumeGcTime }); const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime }); - const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime }); + const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), enabled: editable, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime }); useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]); const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } }); const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN); @@ -112,7 +112,7 @@ export default function AccessPage() { const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); }; const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); }; const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data; - const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), unresolvedQuery.refetch(), thresholdQuery.refetch()]); + const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), unresolvedQuery.refetch(), ...(editable ? [thresholdQuery.refetch()] : [])]); return

车辆接入管理

以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间

数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}
@@ -125,7 +125,7 @@ export default function AccessPage() {
车辆协议接入差异优先展示应接与实接差异;时间为各协议最后接收时间
{PROTOCOLS.map((item) => )}{rows.map((row) => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}>{PROTOCOLS.map((protocol) => )})}
车辆品牌 / 车型应接协议{item}综合状态
{row.plate || '未绑定车牌'}{row.vin}{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}{row.expectedProtocols.length} 项{row.expectedProtocols.join(' / ')}
{vehiclesQuery.isFetching ?
正在更新车辆接入状态…
: null}{!vehiclesQuery.isFetching && !rows.length ?
当前筛选条件没有车辆
: null}
第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆
{selected ? setSelectedVIN('')} /> : null}
{unresolvedQuery.isError ? unresolvedQuery.refetch()} /> : null} - {thresholdQuery.isError ? thresholdQuery.refetch()} /> : null} - thresholdDraft && updateThreshold.mutate(thresholdDraft)} /> + {editable && thresholdQuery.isError ? thresholdQuery.refetch()} /> : null} + {editable ? thresholdDraft && updateThreshold.mutate(thresholdDraft)} /> : null}
; }