Skip to content

Commit 3655bc9

Browse files
committed
feat: add file storage actions log
1 parent de9cbeb commit 3655bc9

File tree

15 files changed

+553
-50
lines changed

15 files changed

+553
-50
lines changed

api/models/file-storage-user-action.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const ACTION_TYPES = [
2+
// make sure to keep in sync with file storage history messages in parseAction()
3+
// from /frontend/pages/sites/$siteId/storage/logs
24
'CREATE_SITE_FILE_STORAGE_SERVICE',
35
'CREATE_ORGANIZATION_FILE_STORAGE_SERVICE',
46
'CREATE_DIRECTORY',

frontend/hooks/useFileStorage.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export default function useFileStorage(
5858
refetchInterval: REFETCH_INTERVAL,
5959
refetchIntervalInBackground: false,
6060
enabled: !!fileStorageId,
61-
keepPreviousData: true,
6261
staleTime: 2000,
6362
placeholderData: previousData.current || INITIAL_DATA,
6463
onError: (err) => {

frontend/hooks/useFileStorageLogs.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { useRef, useEffect } from 'react';
3+
4+
import api from '../util/federalistApi';
5+
6+
const INITIAL_DATA = {
7+
data: [],
8+
currentPage: 1,
9+
totalPages: 1,
10+
totalItems: 0,
11+
};
12+
13+
export default function useFileStorageLogs(fileStorageServiceId, page = 1) {
14+
const previousData = useRef();
15+
16+
const { data, error, isPending, isPlaceholderData } = useQuery({
17+
queryKey: ['fileHistory', fileStorageServiceId, page],
18+
queryFn: async () => {
19+
const response = await api.fetchAllPublicFilesHistory(fileStorageServiceId, page);
20+
return response || INITIAL_DATA;
21+
},
22+
enabled: !!fileStorageServiceId,
23+
staleTime: 60000, // 1 minte between refetches
24+
placeholderData: previousData.current || INITIAL_DATA,
25+
});
26+
27+
useEffect(() => {
28+
if (data !== undefined) {
29+
previousData.current = data;
30+
}
31+
}, [data]);
32+
33+
return {
34+
...data,
35+
isPending,
36+
isPlaceholderData,
37+
error,
38+
};
39+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { waitFor, renderHook } from '@testing-library/react';
2+
import nock from 'nock';
3+
import { createTestQueryClient } from '@support/queryClient';
4+
import useFileStorageLogs from './useFileStorageLogs';
5+
import { getFileStorageLogs, getFileStorageLogsError } from '@support/nocks/fileStorage';
6+
import { getFileStorageLogsData } from '../../test/frontend/support/data/fileStorageData';
7+
import federalist from '@util/federalistApi';
8+
9+
const createWrapper = createTestQueryClient();
10+
11+
const props = {
12+
fileStorageServiceId: 123,
13+
page: 1,
14+
};
15+
16+
describe('useFileStorageLogs', () => {
17+
beforeEach(() => nock.cleanAll());
18+
afterAll(() => nock.restore());
19+
20+
it('should fetch file logs successfully', async () => {
21+
const expectedData = getFileStorageLogsData(props.page);
22+
await getFileStorageLogs({ ...props });
23+
24+
const { result } = renderHook(
25+
() => useFileStorageLogs(props.fileStorageServiceId, props.page),
26+
{ wrapper: createWrapper() },
27+
);
28+
29+
await waitFor(() => expect(!result.current.isPlaceholderData).toBe(true));
30+
await waitFor(() => expect(!result.current.isPending).toBe(true));
31+
32+
expect(result.current.data).toEqual(expectedData.data);
33+
expect(result.current.currentPage).toEqual(expectedData.currentPage);
34+
expect(result.current.totalPages).toEqual(expectedData.totalPages);
35+
expect(result.current.totalItems).toEqual(expectedData.totalItems);
36+
});
37+
38+
it('should fetch the second page of results correctly', async () => {
39+
const pageNumber = 2;
40+
const expectedData = getFileStorageLogsData(pageNumber);
41+
await getFileStorageLogs({ ...props, page: pageNumber });
42+
43+
const { result } = renderHook(
44+
() => useFileStorageLogs(props.fileStorageServiceId, pageNumber),
45+
{ wrapper: createWrapper() },
46+
);
47+
48+
await waitFor(() => expect(!result.current.isPlaceholderData).toBe(true));
49+
await waitFor(() => expect(!result.current.isPending).toBe(true));
50+
51+
expect(result.current.data).toEqual(expectedData.data);
52+
expect(result.current.data.length).toEqual(expectedData.data.length);
53+
expect(result.current.currentPage).toEqual(expectedData.currentPage);
54+
});
55+
56+
it('should return an error when fetching logs fails', async () => {
57+
await getFileStorageLogsError({ ...props });
58+
59+
const { result } = renderHook(() => useFileStorageLogs(props.fileStorageServiceId), {
60+
wrapper: createWrapper(),
61+
});
62+
63+
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
64+
expect(result.current.error.message).toBe('Failed to fetch storage logs');
65+
});
66+
67+
it('should not fetch when fileStorageServiceId is not provided', async () => {
68+
// Spy on the API call
69+
const apiFetchSpy = jest.spyOn(federalist, 'fetchAllPublicFilesHistory');
70+
71+
const { result } = renderHook(() => useFileStorageLogs(), {
72+
wrapper: createWrapper(),
73+
});
74+
75+
// Check initial data
76+
expect(result.current.data).toEqual([]);
77+
78+
// Most importantly - verify the API was never called
79+
expect(apiFetchSpy).not.toHaveBeenCalled();
80+
});
81+
82+
it('should refetch when page changes', async () => {
83+
await getFileStorageLogs({ ...props });
84+
const { result, rerender } = renderHook(
85+
({ page }) => useFileStorageLogs(props.fileStorageServiceId, page),
86+
{
87+
wrapper: createWrapper(),
88+
initialProps: { page: 1 },
89+
},
90+
);
91+
92+
await waitFor(() => expect(!result.current.isPending).toBe(true));
93+
94+
await getFileStorageLogs({ ...props, page: 2 });
95+
rerender({ page: 2 });
96+
97+
await waitFor(() => expect(result.current.currentPage).toBe(2));
98+
});
99+
});

frontend/pages/sites/$siteId/storage/NewFileOrFolder.jsx

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React, { useState } from 'react';
22
import PropTypes from 'prop-types';
33
import FileUpload from '@shared/FileUpload';
4-
import { IconFolder } from '@shared/icons';
5-
4+
import { IconFolder, IconAttachment } from '@shared/icons';
5+
import AlertBanner from '@shared/alertBanner';
6+
import { Link } from 'react-router-dom';
67
const NewFileOrFolder = ({ onUpload, onCreateFolder }) => {
78
const [folderName, setFolderName] = useState('');
89
const [creatingFolder, setCreatingFolder] = useState(false);
@@ -30,16 +31,36 @@ const NewFileOrFolder = ({ onUpload, onCreateFolder }) => {
3031

3132
setCreatingFolder(false);
3233
};
33-
34+
const docsLink = 'https://cloud.gov/pages/documentation'; // TODO: update link
35+
const message = (
36+
<>
37+
Before uploading, carefully review your file to ensure it does not contain any
38+
Personally Identifiable Information (PII) or sensitive data. We{' '}
39+
<Link to="./storage/logs"> log every file upload and deletion </Link> for security
40+
tracking. For more about Public File Storage, review the{' '}
41+
<a target="_blank" rel="noreferrer" className="usa-link" href={docsLink}>
42+
documentation
43+
</a>
44+
.
45+
</>
46+
);
3447
return (
3548
<div className="new-file-or-folder">
3649
{errorMessage && <div className="usa-error-message">{errorMessage}</div>}
3750
{showFileDropZone && (
38-
<FileUpload
39-
onUpload={onUpload}
40-
onCancel={() => setShowFileDropZone(false)}
41-
triggerOnMount
42-
/>
51+
<>
52+
<AlertBanner
53+
className="margin-top-1"
54+
status="warning"
55+
message={message}
56+
alertRole={false}
57+
/>
58+
<FileUpload
59+
onUpload={onUpload}
60+
onCancel={() => setShowFileDropZone(false)}
61+
triggerOnMount
62+
/>
63+
</>
4364
)}
4465
{showFolderNameField && (
4566
<div className="new-folder grid-row flex-align-center margin-y-1">
@@ -71,24 +92,30 @@ const NewFileOrFolder = ({ onUpload, onCreateFolder }) => {
7192
)}
7293
<div className="margin-y-1">
7394
{!showFolderNameField && !showFileDropZone && (
74-
<button
75-
type="button"
76-
className="usa-button usa-button--outline"
77-
onClick={() => setShowFileDropZone(true)}
78-
>
79-
<IconFolder className="usa-icon" />
80-
Upload files
81-
</button>
82-
)}
83-
{!showFileDropZone && !showFolderNameField && (
84-
<button
85-
type="button"
86-
className="usa-button usa-button--outline"
87-
onClick={() => setShowFolderNameField(true)}
88-
>
89-
<IconFolder className="usa-icon" />
90-
New folder
91-
</button>
95+
<>
96+
<button
97+
type="button"
98+
className="usa-button"
99+
onClick={() => setShowFileDropZone(true)}
100+
>
101+
<IconAttachment className="usa-icon" />
102+
Upload files
103+
</button>
104+
<button
105+
type="button"
106+
className="usa-button usa-button--outline"
107+
onClick={() => setShowFolderNameField(true)}
108+
>
109+
<IconFolder className="usa-icon" />
110+
New folder
111+
</button>
112+
<Link
113+
className="usa-button usa-button--unstyled text-top padding-105"
114+
to="./logs"
115+
>
116+
View file history logs
117+
</Link>
118+
</>
92119
)}
93120
</div>
94121
</div>

frontend/pages/sites/$siteId/storage/NewFileOrFolder.test.jsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
33
import '@testing-library/jest-dom';
4+
import { MemoryRouter } from 'react-router-dom';
45
import userEvent from '@testing-library/user-event';
56
import NewFileOrFolder from './NewFileOrFolder';
67
import prettyBytes from 'pretty-bytes';
@@ -9,13 +10,19 @@ jest.mock('pretty-bytes', () => jest.fn());
910
const mockOnUpload = jest.fn();
1011
const mockOnCreateFolder = jest.fn();
1112

13+
jest.mock('@shared/alertBanner', () => {
14+
return jest.fn(() => null);
15+
});
16+
1217
const renderComponent = (props = {}) => {
1318
return render(
14-
<NewFileOrFolder
15-
onUpload={mockOnUpload}
16-
onCreateFolder={mockOnCreateFolder}
17-
{...props}
18-
/>,
19+
<MemoryRouter>
20+
<NewFileOrFolder
21+
onUpload={mockOnUpload}
22+
onCreateFolder={mockOnCreateFolder}
23+
{...props}
24+
/>
25+
</MemoryRouter>,
1926
);
2027
};
2128

frontend/pages/sites/$siteId/storage/index.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ function FileStoragePage() {
1717
const { id } = useParams();
1818
const site = useSelector((state) => currentSite(state.sites, id));
1919
const fileStorageServiceId = site.fileStorageServiceId;
20+
if (!fileStorageServiceId) {
21+
const errorMessage = (
22+
<span>
23+
This site does not have Public File Storage enabled. Please contact{' '}
24+
<a
25+
title="Email support to launch a custom domain."
26+
href="mailto:[email protected]"
27+
>
28+
29+
</a>{' '}
30+
to request access.
31+
</span>
32+
);
33+
return <AlertBanner status="info" header="" message={errorMessage} />;
34+
}
35+
2036
const [searchParams, setSearchParams] = useSearchParams();
2137
let path = decodeURIComponent(searchParams.get('path') || '/').replace(/^\/+/, '/');
2238
// Ensure path always ends in "/" because we use it for asset url links

0 commit comments

Comments
 (0)