@dhis2/app-service-offline 3.0.0-beta.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ var _appServiceAlerts = require("@dhis2/app-service-alerts");
4
+
3
5
  var _react = require("@testing-library/react");
4
6
 
5
7
  var _react2 = _interopRequireDefault(require("react"));
@@ -66,13 +68,13 @@ const TestSection = ({
66
68
 
67
69
  const TestSingleSection = props => {
68
70
  // Props are spread so they can be overwritten
69
- return /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
71
+ return /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
70
72
  offlineInterface: _testMocks.mockOfflineInterface
71
73
  }, props), /*#__PURE__*/_react2.default.createElement(TestControls, _extends({
72
74
  id: '1'
73
75
  }, props)), /*#__PURE__*/_react2.default.createElement(TestSection, _extends({
74
76
  id: '1'
75
- }, props)));
77
+ }, props))));
76
78
  }; // Suppress 'act' warning for these tests
77
79
 
78
80
 
@@ -227,7 +229,7 @@ describe('Coordination between useCacheableSection and CacheableSection', () =>
227
229
  const TwoTestSections = props =>
228
230
  /*#__PURE__*/
229
231
  // Props are spread so they can be overwritten (but only on one section)
230
- _react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
232
+ _react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
231
233
  offlineInterface: _testMocks.mockOfflineInterface
232
234
  }, props), /*#__PURE__*/_react2.default.createElement(TestControls, _extends({
233
235
  id: '1'
@@ -237,7 +239,7 @@ _react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
237
239
  id: '2'
238
240
  }), /*#__PURE__*/_react2.default.createElement(TestSection, {
239
241
  id: '2'
240
- })); // test that other sections don't rerender when one section does
242
+ }))); // test that other sections don't rerender when one section does
241
243
 
242
244
 
243
245
  describe('Performant state management', () => {
@@ -286,13 +288,13 @@ describe('Performant state management', () => {
286
288
  describe('useCacheableSection can be used inside a child of CacheableSection', () => {
287
289
  const ChildTest = props => {
288
290
  // Props are spread so they can be overwritten
289
- return /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
291
+ return /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, _extends({
290
292
  offlineInterface: _testMocks.mockOfflineInterface
291
293
  }, props), /*#__PURE__*/_react2.default.createElement(TestSection, _extends({
292
294
  id: '1'
293
295
  }, props), /*#__PURE__*/_react2.default.createElement(TestControls, _extends({
294
296
  id: '1'
295
- }, props))));
297
+ }, props)))));
296
298
  };
297
299
 
298
300
  it('handles a successful recording', async done => {
@@ -33,6 +33,12 @@ Object.defineProperty(exports, "useOnlineStatus", {
33
33
  return _onlineStatus.useOnlineStatus;
34
34
  }
35
35
  });
36
+ Object.defineProperty(exports, "clearSensitiveCaches", {
37
+ enumerable: true,
38
+ get: function () {
39
+ return _clearSensitiveCaches.clearSensitiveCaches;
40
+ }
41
+ });
36
42
 
37
43
  var _offlineProvider = require("./lib/offline-provider");
38
44
 
@@ -40,4 +46,6 @@ var _cacheableSection = require("./lib/cacheable-section");
40
46
 
41
47
  var _cacheableSectionState = require("./lib/cacheable-section-state");
42
48
 
43
- var _onlineStatus = require("./lib/online-status");
49
+ var _onlineStatus = require("./lib/online-status");
50
+
51
+ var _clearSensitiveCaches = require("./lib/clear-sensitive-caches");
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+
3
+ var _FDBFactory = _interopRequireDefault(require("fake-indexeddb/lib/FDBFactory"));
4
+
5
+ var _idb = require("idb");
6
+
7
+ require("fake-indexeddb/auto");
8
+
9
+ var _clearSensitiveCaches = require("../clear-sensitive-caches");
10
+
11
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
12
+
13
+ // Mocks for CacheStorage API
14
+ // Returns true if an existing cache is deleted
15
+ const makeCachesDeleteMock = keys => {
16
+ return jest.fn().mockImplementation(key => Promise.resolve(keys.includes(key)));
17
+ };
18
+
19
+ const keysMockDefault = jest.fn().mockImplementation(async () => []);
20
+ const deleteMockDefault = makeCachesDeleteMock([]);
21
+ const cachesDefault = {
22
+ keys: keysMockDefault,
23
+ delete: deleteMockDefault
24
+ };
25
+ window.caches = cachesDefault;
26
+ afterEach(() => {
27
+ window.caches = cachesDefault;
28
+ jest.clearAllMocks();
29
+ }); // silence debug logs for these tests
30
+
31
+ const originalDebug = console.debug;
32
+ beforeAll(() => {
33
+ jest.spyOn(console, 'debug').mockImplementation((...args) => {
34
+ const pattern = /Clearing sensitive caches/;
35
+
36
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
37
+ return;
38
+ }
39
+
40
+ return originalDebug.call(console, ...args);
41
+ });
42
+ });
43
+ afterAll(() => {
44
+ ;
45
+ console.debug.mockRestore();
46
+ });
47
+ it('does not fail if there are no caches or no sections-db', () => {
48
+ return expect((0, _clearSensitiveCaches.clearSensitiveCaches)()).resolves.toBe(false);
49
+ });
50
+ it('clears potentially sensitive caches', async () => {
51
+ const testKeys = ['cache1', 'cache2', 'app-shell'];
52
+ const keysMock = jest.fn().mockImplementation(() => Promise.resolve(testKeys));
53
+ const deleteMock = makeCachesDeleteMock(testKeys);
54
+ window.caches = {
55
+ keys: keysMock,
56
+ delete: deleteMock
57
+ };
58
+ const cachesDeleted = await (0, _clearSensitiveCaches.clearSensitiveCaches)();
59
+ expect(cachesDeleted).toBe(true);
60
+ expect(deleteMock).toHaveBeenCalledTimes(3);
61
+ expect(deleteMock.mock.calls[0][0]).toBe('cache1');
62
+ expect(deleteMock.mock.calls[1][0]).toBe('cache2');
63
+ expect(deleteMock.mock.calls[2][0]).toBe('app-shell');
64
+ });
65
+ it('preserves keepable caches', async () => {
66
+ const keysMock = jest.fn().mockImplementation(async () => ['cache1', 'cache2', 'app-shell', 'other-assets', 'workbox-precache-v2-https://hey.howareya.now/']);
67
+ window.caches = { ...cachesDefault,
68
+ keys: keysMock
69
+ };
70
+ await (0, _clearSensitiveCaches.clearSensitiveCaches)();
71
+ expect(deleteMockDefault).toHaveBeenCalledTimes(3);
72
+ expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1');
73
+ expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2');
74
+ expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell');
75
+ expect(deleteMockDefault).not.toHaveBeenCalledWith('other-assets');
76
+ expect(deleteMockDefault).not.toHaveBeenCalledWith('workbox-precache-v2-https://hey.howareya.now/');
77
+ });
78
+ describe('clears sections-db', () => {
79
+ // Test DB
80
+ function openTestDB(dbName) {
81
+ // simplified version of app platform openDB logic
82
+ return (0, _idb.openDB)(dbName, 1, {
83
+ upgrade(db) {
84
+ db.createObjectStore(_clearSensitiveCaches.SECTIONS_STORE, {
85
+ keyPath: 'sectionId'
86
+ });
87
+ }
88
+
89
+ });
90
+ }
91
+
92
+ afterEach(() => {
93
+ // reset indexedDB state
94
+ window.indexedDB = new _FDBFactory.default();
95
+ });
96
+ it('clears sections-db if it exists', async () => {
97
+ // Open and populate test DB
98
+ const db = await openTestDB(_clearSensitiveCaches.SECTIONS_DB);
99
+ await db.put(_clearSensitiveCaches.SECTIONS_STORE, {
100
+ sectionId: 'id-1',
101
+ lastUpdated: new Date(),
102
+ requests: 3
103
+ });
104
+ await db.put(_clearSensitiveCaches.SECTIONS_STORE, {
105
+ sectionId: 'id-2',
106
+ lastUpdated: new Date(),
107
+ requests: 3
108
+ });
109
+ await (0, _clearSensitiveCaches.clearSensitiveCaches)(); // Sections-db should be cleared
110
+
111
+ const allSections = await db.getAll(_clearSensitiveCaches.SECTIONS_STORE);
112
+ expect(allSections).toHaveLength(0);
113
+ });
114
+ it("doesn't clear sections-db if it doesn't exist and doesn't open a new one", async () => {
115
+ const openMock = jest.fn();
116
+ window.indexedDB.open = openMock;
117
+ expect(await indexedDB.databases()).not.toContain(_clearSensitiveCaches.SECTIONS_DB);
118
+ await (0, _clearSensitiveCaches.clearSensitiveCaches)();
119
+ expect(openMock).not.toHaveBeenCalled();
120
+ return expect(await indexedDB.databases()).not.toContain(_clearSensitiveCaches.SECTIONS_DB);
121
+ });
122
+ it("doesn't handle IDB if 'databases' property is not on window.indexedDB", async () => {
123
+ // Open DB -- 'indexedDB.open' _would_ get called in this test
124
+ // if 'databases' property exists
125
+ await openTestDB(_clearSensitiveCaches.SECTIONS_DB);
126
+ const openMock = jest.fn();
127
+ window.indexedDB.open = openMock; // Remove 'databases' from indexedDB prototype for this test
128
+ // (simulates Firefox environment)
129
+
130
+ const idbProto = Object.getPrototypeOf(window.indexedDB);
131
+ const databases = idbProto.databases;
132
+ delete idbProto.databases;
133
+ expect('databases' in window.indexedDB).toBe(false);
134
+ await expect((0, _clearSensitiveCaches.clearSensitiveCaches)()).resolves.toBeDefined();
135
+ expect(openMock).not.toHaveBeenCalled(); // Restore indexedDB prototype for later tests
136
+
137
+ idbProto.databases = databases;
138
+ expect('databases' in window.indexedDB).toBe(true);
139
+ });
140
+ });
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ var _appServiceAlerts = require("@dhis2/app-service-alerts");
4
+
3
5
  var _react = require("@testing-library/react");
4
6
 
5
7
  var _react2 = _interopRequireDefault(require("react"));
@@ -34,17 +36,17 @@ afterEach(() => {
34
36
  });
35
37
  describe('Testing offline provider', () => {
36
38
  it('Should render without failing', () => {
37
- (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
39
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
38
40
  offlineInterface: _testMocks.mockOfflineInterface
39
41
  }, /*#__PURE__*/_react2.default.createElement("div", {
40
42
  "data-testid": "test-div"
41
- })));
43
+ }))));
42
44
  expect(_react.screen.getByTestId('test-div')).toBeInTheDocument();
43
45
  });
44
46
  it('Should initialize the offline interface with an update prompt', () => {
45
- (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
47
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
46
48
  offlineInterface: _testMocks.mockOfflineInterface
47
- }));
49
+ })));
48
50
  expect(_testMocks.mockOfflineInterface.init).toHaveBeenCalledTimes(1); // Expect to have been called with a 'promptUpdate' function
49
51
 
50
52
  const arg = _testMocks.mockOfflineInterface.init.mock.calls[0][0];
@@ -71,9 +73,9 @@ describe('Testing offline provider', () => {
71
73
  }, JSON.stringify(cachedSections));
72
74
  };
73
75
 
74
- (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
76
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
75
77
  offlineInterface: testOfflineInterface
76
- }, /*#__PURE__*/_react2.default.createElement(CachedSections, null)));
78
+ }, /*#__PURE__*/_react2.default.createElement(CachedSections, null))));
77
79
  const {
78
80
  getByTestId
79
81
  } = _react.screen;
@@ -100,26 +102,26 @@ describe('Testing offline provider', () => {
100
102
  }));
101
103
  };
102
104
 
103
- (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
105
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
104
106
  offlineInterface: _testMocks.mockOfflineInterface
105
- }, /*#__PURE__*/_react2.default.createElement(TestConsumer, null)));
107
+ }, /*#__PURE__*/_react2.default.createElement(TestConsumer, null))));
106
108
  expect(_react.screen.getByTestId('test-div')).toBeInTheDocument();
107
109
  });
108
110
  it('Should render without failing when no offlineInterface is provided', () => {
109
- (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, null, /*#__PURE__*/_react2.default.createElement("div", {
111
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, null, /*#__PURE__*/_react2.default.createElement("div", {
110
112
  "data-testid": "test-div"
111
- })));
113
+ }))));
112
114
  expect(_react.screen.getByTestId('test-div')).toBeInTheDocument();
113
115
  });
114
116
  it('Should render without failing if PWA is not enabled', () => {
115
117
  const testOfflineInterface = { ..._testMocks.mockOfflineInterface,
116
118
  pwaEnabled: false
117
119
  };
118
- (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
120
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react2.default.createElement(_offlineProvider.OfflineProvider, {
119
121
  offlineInterface: testOfflineInterface
120
122
  }, /*#__PURE__*/_react2.default.createElement("div", {
121
123
  "data-testid": "test-div"
122
- }))); // Init should still be called - see comments in offline-provider.js
124
+ })))); // Init should still be called - see comments in offline-provider.js
123
125
 
124
126
  expect(testOfflineInterface.init).toHaveBeenCalled();
125
127
  expect(_react.screen.getByTestId('test-div')).toBeInTheDocument();
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ var _appServiceAlerts = require("@dhis2/app-service-alerts");
4
+
3
5
  var _reactHooks = require("@testing-library/react-hooks");
4
6
 
5
7
  var _react = _interopRequireDefault(require("react"));
@@ -37,9 +39,9 @@ it('renders in the default state initially', () => {
37
39
  } = (0, _reactHooks.renderHook)(() => (0, _cacheableSection.useCacheableSection)('one'), {
38
40
  wrapper: ({
39
41
  children
40
- }) => /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
42
+ }) => /*#__PURE__*/_react.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
41
43
  offlineInterface: _testMocks.mockOfflineInterface
42
- }, children)
44
+ }, children))
43
45
  });
44
46
  expect(result.current.recordingState).toBe('default');
45
47
  expect(result.current.isCached).toBe(false);
@@ -59,9 +61,9 @@ it('handles a successful recording', async done => {
59
61
  } = (0, _reactHooks.renderHook)(() => (0, _cacheableSection.useCacheableSection)(sectionId), {
60
62
  wrapper: ({
61
63
  children
62
- }) => /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
64
+ }) => /*#__PURE__*/_react.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
63
65
  offlineInterface: testOfflineInterface
64
- }, children)
66
+ }, children))
65
67
  });
66
68
 
67
69
  const assertRecordingStarted = () => {
@@ -119,9 +121,9 @@ it('handles a recording that encounters an error', async done => {
119
121
  } = (0, _reactHooks.renderHook)(() => (0, _cacheableSection.useCacheableSection)('one'), {
120
122
  wrapper: ({
121
123
  children
122
- }) => /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
124
+ }) => /*#__PURE__*/_react.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
123
125
  offlineInterface: testOfflineInterface
124
- }, children)
126
+ }, children))
125
127
  });
126
128
 
127
129
  const assertRecordingStarted = () => {
@@ -159,9 +161,9 @@ it('handles an error starting the recording', async () => {
159
161
  } = (0, _reactHooks.renderHook)(() => (0, _cacheableSection.useCacheableSection)('err'), {
160
162
  wrapper: ({
161
163
  children
162
- }) => /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
164
+ }) => /*#__PURE__*/_react.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
163
165
  offlineInterface: testOfflineInterface
164
- }, children)
166
+ }, children))
165
167
  });
166
168
  await expect(result.current.startRecording()).rejects.toThrow('Failed message' // from failedMessageRecordingMock
167
169
  );
@@ -180,9 +182,9 @@ it('handles remove and updates sections', async () => {
180
182
  } = (0, _reactHooks.renderHook)(() => (0, _cacheableSection.useCacheableSection)(sectionId), {
181
183
  wrapper: ({
182
184
  children
183
- }) => /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
185
+ }) => /*#__PURE__*/_react.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
184
186
  offlineInterface: testOfflineInterface
185
- }, children)
187
+ }, children))
186
188
  }); // Wait for state to sync with indexedDB
187
189
 
188
190
  await waitFor(() => result.current.isCached === true);
@@ -211,9 +213,9 @@ it('handles a change in ID', async () => {
211
213
  } = (0, _reactHooks.renderHook)((...args) => (0, _cacheableSection.useCacheableSection)(...args), {
212
214
  wrapper: ({
213
215
  children
214
- }) => /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
216
+ }) => /*#__PURE__*/_react.default.createElement(_appServiceAlerts.AlertsProvider, null, /*#__PURE__*/_react.default.createElement(_offlineProvider.OfflineProvider, {
215
217
  offlineInterface: testOfflineInterface
216
- }, children),
218
+ }, children)),
217
219
  initialProps: 'id-one'
218
220
  }); // Wait for state to sync with indexedDB
219
221
 
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.clearSensitiveCaches = clearSensitiveCaches;
7
+ exports.SECTIONS_STORE = exports.SECTIONS_DB = void 0;
8
+ // IndexedDB names; should be the same as in @dhis2/pwa
9
+ const SECTIONS_DB = 'sections-db';
10
+ exports.SECTIONS_DB = SECTIONS_DB;
11
+ const SECTIONS_STORE = 'sections-store'; // Non-sensitive caches that can be kept:
12
+
13
+ exports.SECTIONS_STORE = SECTIONS_STORE;
14
+ const KEEPABLE_CACHES = [/^workbox-precache/, // precached static assets
15
+ /^other-assets/ // static assets cached at runtime - shouldn't be sensitive
16
+ ];
17
+
18
+ /*
19
+ * Clears the 'sections-db' IndexedDB if it exists. Designed to avoid opening
20
+ * a new DB if it doesn't exist yet. Firefox can't check if 'sections-db'
21
+ * exists, in which circumstance the IndexedDB is unaffected. It's inelegant
22
+ * but acceptable because the IndexedDB has no sensitive data (only metadata
23
+ * of recorded sections), and the OfflineInterface handles discrepancies
24
+ * between CacheStorage and IndexedDB.
25
+ */
26
+ const clearDB = async dbName => {
27
+ if (!('databases' in indexedDB)) {
28
+ // FF does not have indexedDB.databases. For that, just clear caches,
29
+ // and offline interface will handle discrepancies in PWA apps.
30
+ return;
31
+ }
32
+
33
+ const dbs = await window.indexedDB.databases();
34
+
35
+ if (!dbs.some(({
36
+ name
37
+ }) => name === dbName)) {
38
+ // Sections-db is not created; nothing to do here
39
+ return;
40
+ }
41
+
42
+ return new Promise((resolve, reject) => {
43
+ // IndexedDB fun:
44
+ const openDBRequest = indexedDB.open(dbName);
45
+
46
+ openDBRequest.onsuccess = e => {
47
+ const db = e.target.result;
48
+ const tx = db.transaction(SECTIONS_STORE, 'readwrite'); // When the transaction completes is when the operation is done:
49
+
50
+ tx.oncomplete = () => resolve();
51
+
52
+ tx.onerror = e => reject(e.target.error);
53
+
54
+ const os = tx.objectStore(SECTIONS_STORE);
55
+ const clearReq = os.clear();
56
+
57
+ clearReq.onerror = e => reject(e.target.error);
58
+ };
59
+
60
+ openDBRequest.onerror = e => {
61
+ reject(e.target.error);
62
+ };
63
+ });
64
+ };
65
+ /**
66
+ * Used to clear caches and 'sections-db' IndexedDB when a user logs out or a
67
+ * different user logs in to prevent someone from accessing a different user's
68
+ * caches. Should be able to be used in a non-PWA app.
69
+ */
70
+
71
+
72
+ async function clearSensitiveCaches(dbName = SECTIONS_DB) {
73
+ console.debug('Clearing sensitive caches');
74
+ const cacheKeys = await caches.keys();
75
+ return Promise.all([// (Resolves to 'false' because this can't detect if anything was deleted):
76
+ clearDB(dbName).then(() => false), // Remove caches if not in keepable list
77
+ ...cacheKeys.map(key => {
78
+ if (!KEEPABLE_CACHES.some(pattern => pattern.test(key))) {
79
+ return caches.delete(key);
80
+ }
81
+
82
+ return false;
83
+ })]).then(responses => {
84
+ // Return true if any caches have been cleared
85
+ // (caches.delete() returns true if a cache is deleted successfully)
86
+ // PWA apps can reload to restore their app shell cache
87
+ return responses.some(response => response);
88
+ });
89
+ }
@@ -1,5 +1,6 @@
1
1
  function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
2
2
 
3
+ import { AlertsProvider } from '@dhis2/app-service-alerts';
3
4
  import { act, fireEvent, render, screen } from '@testing-library/react';
4
5
  import React from 'react';
5
6
  import { useCacheableSection, CacheableSection } from '../lib/cacheable-section';
@@ -56,13 +57,13 @@ const TestSection = ({
56
57
 
57
58
  const TestSingleSection = props => {
58
59
  // Props are spread so they can be overwritten
59
- return /*#__PURE__*/React.createElement(OfflineProvider, _extends({
60
+ return /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, _extends({
60
61
  offlineInterface: mockOfflineInterface
61
62
  }, props), /*#__PURE__*/React.createElement(TestControls, _extends({
62
63
  id: '1'
63
64
  }, props)), /*#__PURE__*/React.createElement(TestSection, _extends({
64
65
  id: '1'
65
- }, props)));
66
+ }, props))));
66
67
  }; // Suppress 'act' warning for these tests
67
68
 
68
69
 
@@ -217,7 +218,7 @@ describe('Coordination between useCacheableSection and CacheableSection', () =>
217
218
  const TwoTestSections = props =>
218
219
  /*#__PURE__*/
219
220
  // Props are spread so they can be overwritten (but only on one section)
220
- React.createElement(OfflineProvider, _extends({
221
+ React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, _extends({
221
222
  offlineInterface: mockOfflineInterface
222
223
  }, props), /*#__PURE__*/React.createElement(TestControls, _extends({
223
224
  id: '1'
@@ -227,7 +228,7 @@ React.createElement(OfflineProvider, _extends({
227
228
  id: '2'
228
229
  }), /*#__PURE__*/React.createElement(TestSection, {
229
230
  id: '2'
230
- })); // test that other sections don't rerender when one section does
231
+ }))); // test that other sections don't rerender when one section does
231
232
 
232
233
 
233
234
  describe('Performant state management', () => {
@@ -276,13 +277,13 @@ describe('Performant state management', () => {
276
277
  describe('useCacheableSection can be used inside a child of CacheableSection', () => {
277
278
  const ChildTest = props => {
278
279
  // Props are spread so they can be overwritten
279
- return /*#__PURE__*/React.createElement(OfflineProvider, _extends({
280
+ return /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, _extends({
280
281
  offlineInterface: mockOfflineInterface
281
282
  }, props), /*#__PURE__*/React.createElement(TestSection, _extends({
282
283
  id: '1'
283
284
  }, props), /*#__PURE__*/React.createElement(TestControls, _extends({
284
285
  id: '1'
285
- }, props))));
286
+ }, props)))));
286
287
  };
287
288
 
288
289
  it('handles a successful recording', async done => {
package/build/es/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { OfflineProvider } from './lib/offline-provider';
2
2
  export { CacheableSection, useCacheableSection } from './lib/cacheable-section';
3
3
  export { useCachedSections } from './lib/cacheable-section-state';
4
- export { useOnlineStatus } from './lib/online-status';
4
+ export { useOnlineStatus } from './lib/online-status';
5
+ export { clearSensitiveCaches } from './lib/clear-sensitive-caches';
@@ -0,0 +1,132 @@
1
+ import FDBFactory from 'fake-indexeddb/lib/FDBFactory';
2
+ import { openDB } from 'idb';
3
+ import 'fake-indexeddb/auto';
4
+ import { clearSensitiveCaches, SECTIONS_DB, SECTIONS_STORE } from '../clear-sensitive-caches'; // Mocks for CacheStorage API
5
+ // Returns true if an existing cache is deleted
6
+
7
+ const makeCachesDeleteMock = keys => {
8
+ return jest.fn().mockImplementation(key => Promise.resolve(keys.includes(key)));
9
+ };
10
+
11
+ const keysMockDefault = jest.fn().mockImplementation(async () => []);
12
+ const deleteMockDefault = makeCachesDeleteMock([]);
13
+ const cachesDefault = {
14
+ keys: keysMockDefault,
15
+ delete: deleteMockDefault
16
+ };
17
+ window.caches = cachesDefault;
18
+ afterEach(() => {
19
+ window.caches = cachesDefault;
20
+ jest.clearAllMocks();
21
+ }); // silence debug logs for these tests
22
+
23
+ const originalDebug = console.debug;
24
+ beforeAll(() => {
25
+ jest.spyOn(console, 'debug').mockImplementation((...args) => {
26
+ const pattern = /Clearing sensitive caches/;
27
+
28
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
29
+ return;
30
+ }
31
+
32
+ return originalDebug.call(console, ...args);
33
+ });
34
+ });
35
+ afterAll(() => {
36
+ ;
37
+ console.debug.mockRestore();
38
+ });
39
+ it('does not fail if there are no caches or no sections-db', () => {
40
+ return expect(clearSensitiveCaches()).resolves.toBe(false);
41
+ });
42
+ it('clears potentially sensitive caches', async () => {
43
+ const testKeys = ['cache1', 'cache2', 'app-shell'];
44
+ const keysMock = jest.fn().mockImplementation(() => Promise.resolve(testKeys));
45
+ const deleteMock = makeCachesDeleteMock(testKeys);
46
+ window.caches = {
47
+ keys: keysMock,
48
+ delete: deleteMock
49
+ };
50
+ const cachesDeleted = await clearSensitiveCaches();
51
+ expect(cachesDeleted).toBe(true);
52
+ expect(deleteMock).toHaveBeenCalledTimes(3);
53
+ expect(deleteMock.mock.calls[0][0]).toBe('cache1');
54
+ expect(deleteMock.mock.calls[1][0]).toBe('cache2');
55
+ expect(deleteMock.mock.calls[2][0]).toBe('app-shell');
56
+ });
57
+ it('preserves keepable caches', async () => {
58
+ const keysMock = jest.fn().mockImplementation(async () => ['cache1', 'cache2', 'app-shell', 'other-assets', 'workbox-precache-v2-https://hey.howareya.now/']);
59
+ window.caches = { ...cachesDefault,
60
+ keys: keysMock
61
+ };
62
+ await clearSensitiveCaches();
63
+ expect(deleteMockDefault).toHaveBeenCalledTimes(3);
64
+ expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1');
65
+ expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2');
66
+ expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell');
67
+ expect(deleteMockDefault).not.toHaveBeenCalledWith('other-assets');
68
+ expect(deleteMockDefault).not.toHaveBeenCalledWith('workbox-precache-v2-https://hey.howareya.now/');
69
+ });
70
+ describe('clears sections-db', () => {
71
+ // Test DB
72
+ function openTestDB(dbName) {
73
+ // simplified version of app platform openDB logic
74
+ return openDB(dbName, 1, {
75
+ upgrade(db) {
76
+ db.createObjectStore(SECTIONS_STORE, {
77
+ keyPath: 'sectionId'
78
+ });
79
+ }
80
+
81
+ });
82
+ }
83
+
84
+ afterEach(() => {
85
+ // reset indexedDB state
86
+ window.indexedDB = new FDBFactory();
87
+ });
88
+ it('clears sections-db if it exists', async () => {
89
+ // Open and populate test DB
90
+ const db = await openTestDB(SECTIONS_DB);
91
+ await db.put(SECTIONS_STORE, {
92
+ sectionId: 'id-1',
93
+ lastUpdated: new Date(),
94
+ requests: 3
95
+ });
96
+ await db.put(SECTIONS_STORE, {
97
+ sectionId: 'id-2',
98
+ lastUpdated: new Date(),
99
+ requests: 3
100
+ });
101
+ await clearSensitiveCaches(); // Sections-db should be cleared
102
+
103
+ const allSections = await db.getAll(SECTIONS_STORE);
104
+ expect(allSections).toHaveLength(0);
105
+ });
106
+ it("doesn't clear sections-db if it doesn't exist and doesn't open a new one", async () => {
107
+ const openMock = jest.fn();
108
+ window.indexedDB.open = openMock;
109
+ expect(await indexedDB.databases()).not.toContain(SECTIONS_DB);
110
+ await clearSensitiveCaches();
111
+ expect(openMock).not.toHaveBeenCalled();
112
+ return expect(await indexedDB.databases()).not.toContain(SECTIONS_DB);
113
+ });
114
+ it("doesn't handle IDB if 'databases' property is not on window.indexedDB", async () => {
115
+ // Open DB -- 'indexedDB.open' _would_ get called in this test
116
+ // if 'databases' property exists
117
+ await openTestDB(SECTIONS_DB);
118
+ const openMock = jest.fn();
119
+ window.indexedDB.open = openMock; // Remove 'databases' from indexedDB prototype for this test
120
+ // (simulates Firefox environment)
121
+
122
+ const idbProto = Object.getPrototypeOf(window.indexedDB);
123
+ const databases = idbProto.databases;
124
+ delete idbProto.databases;
125
+ expect('databases' in window.indexedDB).toBe(false);
126
+ await expect(clearSensitiveCaches()).resolves.toBeDefined();
127
+ expect(openMock).not.toHaveBeenCalled(); // Restore indexedDB prototype for later tests
128
+
129
+ idbProto.databases = databases;
130
+ expect('databases' in window.indexedDB).toBe(true);
131
+ });
132
+ });
@@ -1,3 +1,4 @@
1
+ import { AlertsProvider } from '@dhis2/app-service-alerts';
1
2
  import { render, screen, waitFor } from '@testing-library/react';
2
3
  import React from 'react';
3
4
  import { mockOfflineInterface } from '../../utils/test-mocks';
@@ -24,17 +25,17 @@ afterEach(() => {
24
25
  });
25
26
  describe('Testing offline provider', () => {
26
27
  it('Should render without failing', () => {
27
- render( /*#__PURE__*/React.createElement(OfflineProvider, {
28
+ render( /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
28
29
  offlineInterface: mockOfflineInterface
29
30
  }, /*#__PURE__*/React.createElement("div", {
30
31
  "data-testid": "test-div"
31
- })));
32
+ }))));
32
33
  expect(screen.getByTestId('test-div')).toBeInTheDocument();
33
34
  });
34
35
  it('Should initialize the offline interface with an update prompt', () => {
35
- render( /*#__PURE__*/React.createElement(OfflineProvider, {
36
+ render( /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
36
37
  offlineInterface: mockOfflineInterface
37
- }));
38
+ })));
38
39
  expect(mockOfflineInterface.init).toHaveBeenCalledTimes(1); // Expect to have been called with a 'promptUpdate' function
39
40
 
40
41
  const arg = mockOfflineInterface.init.mock.calls[0][0];
@@ -61,9 +62,9 @@ describe('Testing offline provider', () => {
61
62
  }, JSON.stringify(cachedSections));
62
63
  };
63
64
 
64
- render( /*#__PURE__*/React.createElement(OfflineProvider, {
65
+ render( /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
65
66
  offlineInterface: testOfflineInterface
66
- }, /*#__PURE__*/React.createElement(CachedSections, null)));
67
+ }, /*#__PURE__*/React.createElement(CachedSections, null))));
67
68
  const {
68
69
  getByTestId
69
70
  } = screen;
@@ -90,26 +91,26 @@ describe('Testing offline provider', () => {
90
91
  }));
91
92
  };
92
93
 
93
- render( /*#__PURE__*/React.createElement(OfflineProvider, {
94
+ render( /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
94
95
  offlineInterface: mockOfflineInterface
95
- }, /*#__PURE__*/React.createElement(TestConsumer, null)));
96
+ }, /*#__PURE__*/React.createElement(TestConsumer, null))));
96
97
  expect(screen.getByTestId('test-div')).toBeInTheDocument();
97
98
  });
98
99
  it('Should render without failing when no offlineInterface is provided', () => {
99
- render( /*#__PURE__*/React.createElement(OfflineProvider, null, /*#__PURE__*/React.createElement("div", {
100
+ render( /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, null, /*#__PURE__*/React.createElement("div", {
100
101
  "data-testid": "test-div"
101
- })));
102
+ }))));
102
103
  expect(screen.getByTestId('test-div')).toBeInTheDocument();
103
104
  });
104
105
  it('Should render without failing if PWA is not enabled', () => {
105
106
  const testOfflineInterface = { ...mockOfflineInterface,
106
107
  pwaEnabled: false
107
108
  };
108
- render( /*#__PURE__*/React.createElement(OfflineProvider, {
109
+ render( /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
109
110
  offlineInterface: testOfflineInterface
110
111
  }, /*#__PURE__*/React.createElement("div", {
111
112
  "data-testid": "test-div"
112
- }))); // Init should still be called - see comments in offline-provider.js
113
+ })))); // Init should still be called - see comments in offline-provider.js
113
114
 
114
115
  expect(testOfflineInterface.init).toHaveBeenCalled();
115
116
  expect(screen.getByTestId('test-div')).toBeInTheDocument();
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable react/display-name, react/prop-types */
2
+ import { AlertsProvider } from '@dhis2/app-service-alerts';
2
3
  import { renderHook, act } from '@testing-library/react-hooks';
3
4
  import React from 'react';
4
5
  import { errorRecordingMock, failedMessageRecordingMock, mockOfflineInterface } from '../../utils/test-mocks';
@@ -28,9 +29,9 @@ it('renders in the default state initially', () => {
28
29
  } = renderHook(() => useCacheableSection('one'), {
29
30
  wrapper: ({
30
31
  children
31
- }) => /*#__PURE__*/React.createElement(OfflineProvider, {
32
+ }) => /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
32
33
  offlineInterface: mockOfflineInterface
33
- }, children)
34
+ }, children))
34
35
  });
35
36
  expect(result.current.recordingState).toBe('default');
36
37
  expect(result.current.isCached).toBe(false);
@@ -50,9 +51,9 @@ it('handles a successful recording', async done => {
50
51
  } = renderHook(() => useCacheableSection(sectionId), {
51
52
  wrapper: ({
52
53
  children
53
- }) => /*#__PURE__*/React.createElement(OfflineProvider, {
54
+ }) => /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
54
55
  offlineInterface: testOfflineInterface
55
- }, children)
56
+ }, children))
56
57
  });
57
58
 
58
59
  const assertRecordingStarted = () => {
@@ -110,9 +111,9 @@ it('handles a recording that encounters an error', async done => {
110
111
  } = renderHook(() => useCacheableSection('one'), {
111
112
  wrapper: ({
112
113
  children
113
- }) => /*#__PURE__*/React.createElement(OfflineProvider, {
114
+ }) => /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
114
115
  offlineInterface: testOfflineInterface
115
- }, children)
116
+ }, children))
116
117
  });
117
118
 
118
119
  const assertRecordingStarted = () => {
@@ -150,9 +151,9 @@ it('handles an error starting the recording', async () => {
150
151
  } = renderHook(() => useCacheableSection('err'), {
151
152
  wrapper: ({
152
153
  children
153
- }) => /*#__PURE__*/React.createElement(OfflineProvider, {
154
+ }) => /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
154
155
  offlineInterface: testOfflineInterface
155
- }, children)
156
+ }, children))
156
157
  });
157
158
  await expect(result.current.startRecording()).rejects.toThrow('Failed message' // from failedMessageRecordingMock
158
159
  );
@@ -171,9 +172,9 @@ it('handles remove and updates sections', async () => {
171
172
  } = renderHook(() => useCacheableSection(sectionId), {
172
173
  wrapper: ({
173
174
  children
174
- }) => /*#__PURE__*/React.createElement(OfflineProvider, {
175
+ }) => /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
175
176
  offlineInterface: testOfflineInterface
176
- }, children)
177
+ }, children))
177
178
  }); // Wait for state to sync with indexedDB
178
179
 
179
180
  await waitFor(() => result.current.isCached === true);
@@ -202,9 +203,9 @@ it('handles a change in ID', async () => {
202
203
  } = renderHook((...args) => useCacheableSection(...args), {
203
204
  wrapper: ({
204
205
  children
205
- }) => /*#__PURE__*/React.createElement(OfflineProvider, {
206
+ }) => /*#__PURE__*/React.createElement(AlertsProvider, null, /*#__PURE__*/React.createElement(OfflineProvider, {
206
207
  offlineInterface: testOfflineInterface
207
- }, children),
208
+ }, children)),
208
209
  initialProps: 'id-one'
209
210
  }); // Wait for state to sync with indexedDB
210
211
 
@@ -0,0 +1,80 @@
1
+ // IndexedDB names; should be the same as in @dhis2/pwa
2
+ export const SECTIONS_DB = 'sections-db';
3
+ export const SECTIONS_STORE = 'sections-store'; // Non-sensitive caches that can be kept:
4
+
5
+ const KEEPABLE_CACHES = [/^workbox-precache/, // precached static assets
6
+ /^other-assets/ // static assets cached at runtime - shouldn't be sensitive
7
+ ];
8
+
9
+ /*
10
+ * Clears the 'sections-db' IndexedDB if it exists. Designed to avoid opening
11
+ * a new DB if it doesn't exist yet. Firefox can't check if 'sections-db'
12
+ * exists, in which circumstance the IndexedDB is unaffected. It's inelegant
13
+ * but acceptable because the IndexedDB has no sensitive data (only metadata
14
+ * of recorded sections), and the OfflineInterface handles discrepancies
15
+ * between CacheStorage and IndexedDB.
16
+ */
17
+ const clearDB = async dbName => {
18
+ if (!('databases' in indexedDB)) {
19
+ // FF does not have indexedDB.databases. For that, just clear caches,
20
+ // and offline interface will handle discrepancies in PWA apps.
21
+ return;
22
+ }
23
+
24
+ const dbs = await window.indexedDB.databases();
25
+
26
+ if (!dbs.some(({
27
+ name
28
+ }) => name === dbName)) {
29
+ // Sections-db is not created; nothing to do here
30
+ return;
31
+ }
32
+
33
+ return new Promise((resolve, reject) => {
34
+ // IndexedDB fun:
35
+ const openDBRequest = indexedDB.open(dbName);
36
+
37
+ openDBRequest.onsuccess = e => {
38
+ const db = e.target.result;
39
+ const tx = db.transaction(SECTIONS_STORE, 'readwrite'); // When the transaction completes is when the operation is done:
40
+
41
+ tx.oncomplete = () => resolve();
42
+
43
+ tx.onerror = e => reject(e.target.error);
44
+
45
+ const os = tx.objectStore(SECTIONS_STORE);
46
+ const clearReq = os.clear();
47
+
48
+ clearReq.onerror = e => reject(e.target.error);
49
+ };
50
+
51
+ openDBRequest.onerror = e => {
52
+ reject(e.target.error);
53
+ };
54
+ });
55
+ };
56
+ /**
57
+ * Used to clear caches and 'sections-db' IndexedDB when a user logs out or a
58
+ * different user logs in to prevent someone from accessing a different user's
59
+ * caches. Should be able to be used in a non-PWA app.
60
+ */
61
+
62
+
63
+ export async function clearSensitiveCaches(dbName = SECTIONS_DB) {
64
+ console.debug('Clearing sensitive caches');
65
+ const cacheKeys = await caches.keys();
66
+ return Promise.all([// (Resolves to 'false' because this can't detect if anything was deleted):
67
+ clearDB(dbName).then(() => false), // Remove caches if not in keepable list
68
+ ...cacheKeys.map(key => {
69
+ if (!KEEPABLE_CACHES.some(pattern => pattern.test(key))) {
70
+ return caches.delete(key);
71
+ }
72
+
73
+ return false;
74
+ })]).then(responses => {
75
+ // Return true if any caches have been cleared
76
+ // (caches.delete() returns true if a cache is deleted successfully)
77
+ // PWA apps can reload to restore their app shell cache
78
+ return responses.some(response => response);
79
+ });
80
+ }
@@ -2,3 +2,4 @@ export { OfflineProvider } from './lib/offline-provider';
2
2
  export { CacheableSection, useCacheableSection } from './lib/cacheable-section';
3
3
  export { useCachedSections } from './lib/cacheable-section-state';
4
4
  export { useOnlineStatus } from './lib/online-status';
5
+ export { clearSensitiveCaches } from './lib/clear-sensitive-caches';
@@ -0,0 +1,16 @@
1
+ export declare const SECTIONS_DB = "sections-db";
2
+ export declare const SECTIONS_STORE = "sections-store";
3
+ declare global {
4
+ interface IDBFactory {
5
+ databases(): Promise<[{
6
+ name: string;
7
+ version: number;
8
+ }]>;
9
+ }
10
+ }
11
+ /**
12
+ * Used to clear caches and 'sections-db' IndexedDB when a user logs out or a
13
+ * different user logs in to prevent someone from accessing a different user's
14
+ * caches. Should be able to be used in a non-PWA app.
15
+ */
16
+ export declare function clearSensitiveCaches(dbName?: string): Promise<any>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dhis2/app-service-offline",
3
3
  "description": "A runtime service for online/offline detection and offline caching",
4
- "version": "3.0.0-beta.1",
4
+ "version": "3.2.0",
5
5
  "main": "./build/cjs/index.js",
6
6
  "module": "./build/es/index.js",
7
7
  "types": "build/types/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "coverage": "yarn test --coverage"
34
34
  },
35
35
  "peerDependencies": {
36
- "@dhis2/app-service-alerts": "3.0.0-beta.1",
36
+ "@dhis2/app-service-alerts": "3.2.0",
37
37
  "prop-types": "^15.7.2",
38
38
  "react": "^16.8.6",
39
39
  "react-dom": "^16.8.6"