feat(history): scope export tasks to owners
This commit is contained in:
@@ -21,6 +21,25 @@ test('authenticated requests use the session-only bearer token', async () => {
|
||||
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
|
||||
});
|
||||
|
||||
test('history export downloads use bearer authentication and retain the server filename', async () => {
|
||||
setAccessToken('customer-export-token');
|
||||
const payload = new Blob(['export'], { type: 'text/csv' });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'X-Export-Name': encodeURIComponent('历史数据_customer-a.csv'), 'Content-Type': 'text/csv' }),
|
||||
blob: async () => payload
|
||||
} as Response);
|
||||
|
||||
const result = await api.downloadHistoryExport('exp customer');
|
||||
|
||||
expect(result.filename).toBe('历史数据_customer-a.csv');
|
||||
expect(result.blob.size).toBe(6);
|
||||
expect(result.blob.type).toBe('text/csv');
|
||||
const [path, init] = fetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/v2/exports/exp%20customer/download');
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer customer-export-token');
|
||||
});
|
||||
|
||||
test('a protected 401 terminates the client session but login validation errors stay local', async () => {
|
||||
window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token');
|
||||
const unauthorized = vi.fn();
|
||||
|
||||
@@ -141,6 +141,29 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBlob(path: string, signal?: AbortSignal) {
|
||||
const token = getAccessToken();
|
||||
const init: RequestInit = token ? { signal, headers: withAuthorization(undefined, token) } : { signal };
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && token) notifyUnauthorizedSession(token);
|
||||
throw new Error(await responseErrorMessage(response));
|
||||
}
|
||||
const encodedFilename = response.headers.get('X-Export-Name')?.trim();
|
||||
let filename = 'history-export.csv';
|
||||
if (encodedFilename) {
|
||||
try {
|
||||
filename = decodeURIComponent(encodedFilename);
|
||||
} catch {
|
||||
filename = encodedFilename;
|
||||
}
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename
|
||||
};
|
||||
}
|
||||
|
||||
function withAuthorization(headers: HeadersInit | undefined, token: string) {
|
||||
const authorized = new Headers(headers);
|
||||
authorized.set('Authorization', `Bearer ${token}`);
|
||||
@@ -201,6 +224,7 @@ export const api = {
|
||||
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
downloadHistoryExport: (id: string, signal?: AbortSignal) => requestBlob(`/api/v2/exports/${encodeURIComponent(id)}/download`, signal),
|
||||
accessSummary: (query: AccessQuery, signal?: AbortSignal) => request<AccessSummary>('/api/v2/access/summary', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
|
||||
@@ -251,7 +251,7 @@ export interface HistorySeriesSummary { rawPointCount: number; bucketCount: numb
|
||||
export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; }
|
||||
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
|
||||
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
|
||||
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
|
||||
|
||||
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }
|
||||
|
||||
Reference in New Issue
Block a user